Deal — единая кодовая база
ci / build-test (push) Canceled after 0s

SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/
Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue,
контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер),
Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог).

Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0,
тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
Rustam Khalimov
2026-09-11 23:56:47 +03:00
commit 27c7831910
1383 changed files with 158436 additions and 0 deletions
@@ -0,0 +1,18 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Данные проверки подключения AI-провайдера.
/// </summary>
/// <param name="ProviderId">Идентификатор провайдера (id из каталога <c>AiProviders</c>, ключ в aiConfigs).</param>
/// <param name="BaseUrl">Эффективный базовый URL: переопределение aiConfigs или дефолт каталога.</param>
/// <param name="Model">Активная модель: переопределение aiConfigs или первая модель каталога.</param>
/// <param name="ApiKey">API-ключ открытым текстом (пустая строка — ключ не задан).</param>
/// <param name="IsLocal">True — локальный сервер (Ollama/LM Studio): ключ не требуется, HTTP не выполняется.</param>
/// <param name="ApiStyle">Стиль API: null — OpenAI-совместимый, <c>"anthropic"</c> — Messages API.</param>
public sealed record AiCheckRequest(
string ProviderId,
string BaseUrl,
string Model,
string ApiKey,
bool IsLocal,
string? ApiStyle);
@@ -0,0 +1,24 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Результат проверки подключения AI-провайдера — тело POST /api/ai/check.
/// </summary>
/// <param name="Ok">True — подключение успешно (включая локальные серверы).</param>
/// <param name="Message">Сообщение для UI.</param>
/// <param name="Provider">Активный провайдер (id из каталога).</param>
/// <param name="Name">Имя провайдера из каталога.</param>
/// <param name="Base">Эффективный базовый URL.</param>
/// <param name="Model">Активная модель.</param>
/// <param name="Local">True — провайдер локальный.</param>
/// <param name="KeySet">True — API-ключ задан (расшифрованный непустой).</param>
/// <param name="KeyMasked">Маска ключа: пусто, «x…» (len ≤ 8) или «1234…5678».</param>
public sealed record AiCheckResultDto(
bool Ok,
string Message,
string Provider,
string Name,
string Base,
string Model,
bool Local,
bool KeySet,
string KeyMasked);
@@ -0,0 +1,10 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Публичная форма конфигурации AI-провайдера в <c>aiConfigs</c>.
/// </summary>
/// <param name="BaseUrl">Базовый URL API провайдера.</param>
/// <param name="Model">Активная модель.</param>
/// <param name="KeySet">True — API-ключ задан (после расшифровки не пустой).</param>
/// <param name="KeyMasked">Маскированный ключ: пусто, как есть (len ≤ 8) или «1234…5678».</param>
public sealed record AiConfigPublicDto(string BaseUrl, string Model, bool KeySet, string KeyMasked);
@@ -0,0 +1,9 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Сохранённая конфигурация AI-провайдера в настройке <c>aiConfigs</c>
/// </summary>
/// <param name="ApiKey">API-ключ: пустая строка или зашифрованное значение с префиксом <c>enc:</c>.</param>
/// <param name="BaseUrl">Базовый URL API (переопределение дефолта провайдера).</param>
/// <param name="Model">Активная модель.</param>
public sealed record AiConfigSetting(string ApiKey, string BaseUrl, string Model);
@@ -0,0 +1,18 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Описание AI-провайдера — зеркало <c>constants.AI_PROVIDERS</c>.
/// </summary>
/// <param name="Id">Идентификатор провайдера (совпадает с ключом в настройке <c>aiConfigs</c>).</param>
/// <param name="Name">Человекочитаемое имя.</param>
/// <param name="Base">Базовый URL API по умолчанию.</param>
/// <param name="Local">True — локальный сервер (Ollama/LM Studio), API-ключ не нужен.</param>
/// <param name="Models">Доступные модели; может быть пустым (кастомный сервер).</param>
/// <param name="ApiStyle">Стиль API: null = OpenAI-совместимый, <c>"anthropic"</c> — Messages API.</param>
public sealed record AiProviderDefinition(
string Id,
string Name,
string Base,
bool Local,
IReadOnlyList<string> Models,
string? ApiStyle);
@@ -0,0 +1,70 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Статический список AI-провайдеров.
/// </summary>
public static class AiProviders
{
/// <summary>
/// Все провайдеры в фиксированном порядке.
/// </summary>
public static readonly IReadOnlyList<AiProviderDefinition> All = new List<AiProviderDefinition>
{
new(
Id: "deepseek",
Name: "DeepSeek",
Base: "https://api.deepseek.com",
Local: false,
Models: new[] { "deepseek-v4-flash", "deepseek-v4-pro", "deepseek-v4-flash-vision-exp" },
ApiStyle: null),
new(
Id: "openai",
Name: "OpenAI",
Base: "https://api.openai.com/v1",
Local: false,
Models: new[] { "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna" },
ApiStyle: null),
new(
Id: "openrouter",
Name: "OpenRouter",
Base: "https://openrouter.ai/api/v1",
Local: false,
Models: new[]
{
"deepseek/deepseek-v4-flash",
"anthropic/claude-sonnet-5",
"openai/gpt-5.6-luna",
"google/gemini-3.8-flash",
"qwen/qwen3.8-flash",
},
ApiStyle: null),
new(
Id: "anthropic",
Name: "Anthropic Claude",
Base: "https://api.anthropic.com",
Local: false,
Models: new[] { "claude-sonnet-5", "claude-opus-5", "claude-fable-5-1", "claude-haiku-4-5-20251001" },
ApiStyle: "anthropic"),
new(
Id: "ollama",
Name: "Ollama (локально)",
Base: "http://localhost:11434/v1",
Local: true,
Models: new[] { "qwen3-coder:30b", "qwen3-coder:480b", "qwen3:32b" },
ApiStyle: null),
new(
Id: "lmstudio",
Name: "LM Studio (локально)",
Base: "http://localhost:1234/v1",
Local: true,
Models: new[] { "qwen3-coder-30b", "qwen3-coder-480b", "llama-3.3-70b" },
ApiStyle: null),
new(
Id: "custom",
Name: "Другой (OpenAI-совместимый)",
Base: "https://",
Local: false,
Models: Array.Empty<string>(),
ApiStyle: null),
};
}
@@ -0,0 +1,82 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Дефолтные тексты ИИ-промптов.
/// </summary>
public static class DefaultPrompts
{
/// <summary>
/// Промпт классификатора входящих сообщений.
/// </summary>
public static readonly string DefaultAiPrompt = Normalize(
"""
Ты — классификатор входящих сообщений. Сообщение — ЗАЯВКА/ОБЪЯВЛЕНИЕ, только если в нём есть конкретный запрос или предложение по делу: заказ услуги/товара/работы, поиск исполнителя или найм человека. Направление вашей сферы:
{domain}
Частые слова-маркеры заявок в вашей сфере: {keywords}
Определи по тексту:
1. is_spam — true, если это НЕ заявка: служебные сообщения (коды входа/подтверждения, уведомления), приветствия и поздравления, флуд и обсуждения без задачи, вопросы без конкретики, реклама, скам, фин. пирамиды, резюме соискателей, взаимный пиар, приглашения в чаты, рассылки. Если сомневаешься — ставь true (лучше пропустить сообщение, чем засорить карточками)
2. board — id подходящей доски из списка. Выбирай по КРИТЕРИЯМ колонки (в скобках указаны её направление, тематика, ключевые слова, уровень, бюджет, описание), а не только по названию; если ни одна колонка не подходит под текст — верни null (карточка пойдёт в «Неразобранное»)
3. is_vacancy — true, если это НАЙМ/постоянная или проектная занятость: ищут человека в команду (признаки: вакансия, грейд/уровень, «в компанию», hr, зарплата за месяц); false — разовая сделка: заказ/услуга/товар (фриланс, подряд, «нужно сделать/купить», цена за работу)
4. title — короткий заголовок (4–9 слов, без эмодзи, хэштегов, markdown-разметки, ссылок и знаков препинания в конце)
5. company — кто разместил заявку: компания/бренд/частное лицо/заказчик. Только факт из текста; не указано — верни ""
6. format — формат работы/выполнения: удалённо/офис/гибрид, город, график. Коротко; нет — ""
7. task — 1–2 предложения: что за задача/роль и в чём суть (для кого, что нужно сделать). Ёмко, без пересказа всего объявления и служебных строк
8. requirements — JSON-массив строк ключевых требований к кандидату/исполнителю (пункты «Требования/Обязанности/Нужно»). Нет — []
9. plus — JSON-массив строк «будет плюсом»/«приветствуется»/«желательно». Нет — []
10. conditions — условия одной строкой: оплата/ЗП/вилка, сроки, объём, тип занятости. Нет — ""
11. stack — JSON-массив строк (не строка!): технологии/предметы/услуги/материалы из заявки. Названия пиши слитно как в оригинале: ".NET", "C#", "Node.js", "ASP.NET Core" — не разбивай на отдельные буквы (2–8)
12. budget — объект { from, to, currency }: одна сумма → from=to=X; «до X» → from=null, to=X; диапазон «от X до Y» → from=X, to=Y. Если суммы нет — null
13. contacts — JSON-массив строк контактов ДЛЯ СВЯЗИ: телефон, email, @username (не бот), ссылка на профиль человека (LinkedIn, t.me/…). НЕ включай ботов, каналы/группы и ссылки на вакансию/пост/форму. Если контакта в тексте нет — пустой массив []
Поля 5–10 — это блок «О заявке» на карточке: заполняй их только фактами из текста, ничего не выдумывай. Запрещено: markdown-разметка, ссылки в любом виде, эмодзи, хэштеги, «от»/«привет», дословное копирование исходника.
Отвечай строго в формате JSON.
""");
/// <summary>
/// Промпт структуры карточки — блок «О заявке».
/// </summary>
public static readonly string DefaultCardPrompt = Normalize(
"""
Ты также возвращаешь содержимое блока «О заявке» карточки — поля company, format, task, requirements, plus, conditions. Карточка всегда собирается из одних и тех же блоков: одинаковая структура у всех карточек, различается только длина.
1. company — кто ищет/разместил: компания, бренд, агентство, частное лицо, заказчик. Одно предложение, факт из текста.
2. format — формат работы: удалённо/офис/гибрид/разъездной, город/страна, график (5/2, full-time, part-time). Коротко.
3. task — 1–2 предложения по шаблону: что за задача/роль → для кого → что нужно сделать/какой результат. Пиши ёмко, не пересказывай объявление дословно.
4. requirements — вынеси сюда только реальные требования/обязанности из текста (пункты списков «Требования», «Обязанности», «Что предстоит делать», «Нужно»): каждый пункт короткой строкой в JSON-массиве. Не придумывай сверх текста.
5. plus — только то, что отмечено как «будет плюсом»/«приветствуется»/«желательно». Нет таких пунктов — пустой массив [].
6. conditions — условия одной строкой: оплата/ЗП/вилка/гонорар, сроки, объём, тип занятости. Не дублируй budget-числа в других полях.
Для не-IT сфер «стек/требования» означают материалы, услуги, навыки, инструменты — по смыслу заявки. Запрещено во всех полях: markdown-разметка, ссылки в любом виде ([текст](url), голые url), эмодзи, хэштеги, «от»/«привет»/служебные строки канала.
""");
/// <summary>
/// Промпт стража входящих
/// </summary>
public static readonly string DefaultAiFilterPrompt = Normalize(
"""
Ты — страж входящих сообщений каналов. Пропускай только реальные заявки/объявления по вашей сфере (заказ, услуга, товар, найм — с конкретикой).
Ваша сфера и что считать заявкой:
{domain}
Слова-маркеры заявок: {keywords}
НЕ пропускай:
- служебные сообщения: коды входа/подтверждения, уведомления, приветствия, поздравления
- просто сообщения без задачи и конкретики: флуд, обсуждения, вопросы «кто работал с …»
- рекламу и саморекламу
- скам, фин. пирамиды, «заработок»
- резюме, поиск работы соискателями
- взаимный пиар, приглашения в чаты
- рассылки и дайджесты без прямых заявок
При сомнении — не пропускай.
Верни строго JSON: { "pass": true|false, "reason": "причина отказа или null" }
""");
// Приводит текст к переводам строк \n и убирает завершающий перевод (raw-литерал добавляет его).
// text: Многострочный текст из raw string literal.
// Возвращает: Текст, идентичный строке шаблона data.js (переводы строк — только \n).
private static string Normalize(string text)
{
return text.Replace("\r\n", "\n", StringComparison.Ordinal).TrimEnd('\n');
}
}
@@ -0,0 +1,9 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Каталог ключей глобальных
/// </summary>
public static class GlobalSettingsKeys
{
public const string TelegramKeys = "telegramKeys";
}
@@ -0,0 +1,220 @@
using Deal.Modules.Settings.Application.Abstractions;
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Этап-1 правила фильтра входящих — чистая реализация <c>stage1_plain</c>.
/// </summary>
public sealed class IncomingRules(ISettingsStore store)
{
/// <summary>
/// Номер пайплайна для результата правил
/// </summary>
public const int Stage = 1;
/// <summary>
/// kind: текст короче minLen.
/// </summary>
public const string KindLength = "length";
/// <summary>
/// kind: найдена стоп-фраза.
/// </summary>
public const string KindStop = "stop";
/// <summary>
/// kind: текст опознан как резюме соискателя.
/// </summary>
public const string KindResume = "resume";
/// <summary>
/// kind: тип текста не совпал с wantedType.
/// </summary>
public const string KindType = "type";
/// <summary>
/// kind на проходе — пустая строка.
/// </summary>
public const string KindPass = "";
private const string ResumeMarkerGuardWord = "резюме";
// wantedType: собираем и вакансии, и разовые заказы (дефолт).
private const string WantedTypeBoth = "both";
// wantedType: только вакансии/занятость.
private const string WantedTypeVacancy = "vacancy";
// wantedType: только разовые заказы/фриланс.
private const string WantedTypeFreelance = "freelance";
private const string ReasonFormatLength = "короче {0} символов";
private const string ReasonFormatStop = "стоп-фраза «{0}»";
private const string ReasonFormatResume = "резюме соискателя («{0}»)";
private const string ReasonTypeFreelanceOnly = "ищете только разовые заявки — сообщение похоже на вакансию/занятость";
private const string ReasonTypeVacancyOnly = "ищете только занятость — сообщение похоже на разовый заказ/услугу";
/// <summary>
/// Проверяет текст -1 фильтра по настройкам тенанта.
/// </summary>
/// <param name="text">Текст сообщения; null/пустой трактуется как «короче minLen» (как <c>(text or "").strip</c>).</param>
/// <returns>Вердикт: {pass, reason, stage:1, kind, kw}.</returns>
public async Task<IncomingRulesResult> CheckAsync(string? text, CancellationToken ct)
{
RulesSettings settings = ReadSettings(await TenantSettingsSnapshot.LoadAsync(store, ct));
return Evaluate(text, settings);
}
private static IncomingRulesResult Evaluate(string? text, RulesSettings settings)
{
string trimmed = (text ?? string.Empty).Trim();
if (trimmed.Length < settings.MinLen)
{
return Fail(
KindLength,
string.Format(ReasonFormatLength, settings.MinLen),
Kw: string.Empty);
}
string lower = trimmed.ToLowerInvariant();
foreach (string phrase in settings.StopPhrases)
{
if (string.IsNullOrEmpty(phrase))
{
continue;
}
if (lower.Contains(phrase.ToLowerInvariant(), StringComparison.Ordinal))
{
return Fail(KindStop, string.Format(ReasonFormatStop, phrase), phrase);
}
}
if (settings.BlockResumes)
{
string? marker = FindResumeMarker(lower, settings.ResumeMarkers, settings.HireMarkers);
if (marker is not null)
{
return Fail(KindResume, string.Format(ReasonFormatResume, marker), marker);
}
}
string wantedType = settings.WantedType;
if (wantedType == WantedTypeVacancy || wantedType == WantedTypeFreelance)
{
bool looksVacancy = ContainsAny(lower, settings.HireMarkers);
if (wantedType == WantedTypeFreelance && looksVacancy)
{
return Fail(KindType, ReasonTypeFreelanceOnly, Kw: string.Empty);
}
if (wantedType == WantedTypeVacancy && !looksVacancy)
{
return Fail(KindType, ReasonTypeVacancyOnly, Kw: string.Empty);
}
}
return new IncomingRulesResult(Pass: true, Reason: null, Stage, KindPass, Kw: string.Empty);
}
private static string? FindResumeMarker(
string lower,
IReadOnlyList<string> resumeMarkers,
IReadOnlyList<string> hireMarkers)
{
foreach (string marker in resumeMarkers)
{
int position = lower.IndexOf(marker, StringComparison.Ordinal);
if (position < 0)
{
continue;
}
if (marker == ResumeMarkerGuardWord
&& ContainsAny(lower.Substring(0, position), hireMarkers))
{
continue;
}
return marker;
}
return null;
}
// Содержит ли текст хотя бы один маркер из списка (подстрочное сравнение).
// lower: Текст в нижнем регистре.
// markers: Нормализованные (нижний регистр) маркеры.
// Возвращает: True — найден хотя бы один маркер.
private static bool ContainsAny(string lower, IReadOnlyList<string> markers)
{
foreach (string marker in markers)
{
if (lower.Contains(marker, StringComparison.Ordinal))
{
return true;
}
}
return false;
}
private static IncomingRulesResult Fail(
string kind,
string reason,
string Kw)
{
return new IncomingRulesResult(Pass: false, Reason: reason, Stage, kind, Kw);
}
private static RulesSettings ReadSettings(TenantSettingsSnapshot settings)
{
return new RulesSettings(
MinLen: settings.GetInt(SettingsKeys.MinLen, SettingsDefaults.MinLen),
StopPhrases: settings.GetStringList(SettingsKeys.StopPhrases, SettingsDefaults.StopPhrases),
BlockResumes: settings.GetBool(SettingsKeys.BlockResumes, SettingsDefaults.BlockResumes),
ResumeMarkers: NormalizeMarkers(
settings.GetStringList(SettingsKeys.ResumeMarkers, SettingsDefaults.ResumeMarkers)),
HireMarkers: NormalizeMarkers(
settings.GetStringList(SettingsKeys.HireMarkers, SettingsDefaults.HireMarkers)),
WantedType: ReadWantedType(settings));
}
// Читает wantedType: строка, trim + lowercase; пустая/отсутствующая/повреждённая → "both".
// settings: Снимок настроек тенанта.
// Возвращает: Значение wantedType (любая строка; тип-гейт активен только для vacancy/freelance).
private static string ReadWantedType(TenantSettingsSnapshot settings)
{
string wanted = settings.GetString(SettingsKeys.WantedType, SettingsDefaults.WantedType)
.Trim().ToLowerInvariant();
return wanted.Length > 0 ? wanted : WantedTypeBoth;
}
private static IReadOnlyList<string> NormalizeMarkers(IReadOnlyList<string> markers)
{
var result = new List<string>(markers.Count);
foreach (string marker in markers)
{
string normalized = marker.Trim().ToLowerInvariant();
if (normalized.Length > 0 && !result.Contains(normalized, StringComparer.Ordinal))
{
result.Add(normalized);
}
}
return result;
}
private sealed record RulesSettings(
int MinLen,
IReadOnlyList<string> StopPhrases,
bool BlockResumes,
IReadOnlyList<string> ResumeMarkers,
IReadOnlyList<string> HireMarkers,
string WantedType);
}
@@ -0,0 +1,16 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Результат -1 правил фильтра входящих — со <c>stage1_plain</c>.
/// </summary>
/// <param name="Pass">True — текст прошёл; False — отсечён правилом.</param>
/// <param name="Reason">Причина для UI или null на проходе.</param>
/// <param name="Stage">Номер пайплайна — всегда 1 (константа <see cref="Deal.Modules.Settings.Application.Models.IncomingRules.Stage"/>).</param>
/// <param name="Kind">Какое правило сработало: length|stop|resume|type; на проходе — "".</param>
/// <param name="Kw">Конкретная стоп-фраза/маркер резюме (для мониторинга отсева); иначе "".</param>
public sealed record IncomingRulesResult(
bool Pass,
string? Reason,
int Stage,
string Kind,
string Kw);
@@ -0,0 +1,10 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Элемент «Моих промптов».
/// </summary>
/// <param name="Id">Идентификатор промпта (начинается с <c>pp_</c>).</param>
/// <param name="Name">Название (как показывается пользователю).</param>
/// <param name="Description">Короткое описание (необязательное).</param>
/// <param name="Prompt">Текст промпта.</param>
public sealed record MyPromptDto(string Id, string Name, string Description, string Prompt);
@@ -0,0 +1,11 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Провайдер ИИ в public-снимке настроек.
/// </summary>
/// <param name="Id">Идентификатор провайдера.</param>
/// <param name="Name">Человекочитаемое имя.</param>
/// <param name="Base">Базовый URL API.</param>
/// <param name="Local">True — локальный сервер (ключ не нужен).</param>
/// <param name="Models">Доступные модели.</param>
public sealed record ProviderPublicDto(string Id, string Name, string Base, bool Local, IReadOnlyList<string> Models);
@@ -0,0 +1,229 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Публичный снимок настроек тенанта — тело ответа GET/PATCH /api/settings.
/// </summary>
public sealed record PublicSettingsDto
{
/// <summary>
/// Авто-архивация выполненных/отклонённых карточек.
/// </summary>
public bool AutoArchive { get; init; }
/// <summary>
/// Дней до авто-архивации карточки
/// </summary>
public int ArchiveAfterDays { get; init; }
/// <summary>
/// Дней хранения в архиве до очистки.
/// </summary>
public int ArchiveClearDays { get; init; }
/// <summary>
/// Дней хранения в корзине до очистки.
/// </summary>
public int TrashClearDays { get; init; }
/// <summary>
/// Минимальная длина сообщения для -1 фильтра
/// </summary>
public int MinLen { get; init; }
/// <summary>
/// Стоп-фразы -1 фильтра.
/// </summary>
public IReadOnlyList<string> StopPhrases { get; init; } = Array.Empty<string>();
/// <summary>
/// Локальный ML-слой включён.
/// </summary>
public bool MlEnabled { get; init; }
/// <summary>
/// Полный выключатель ИИ
/// </summary>
public bool AiEnabled { get; init; }
/// <summary>
/// ИИ-фильтр (страж) входящих включён.
/// </summary>
public bool AiFilterEnabled { get; init; }
/// <summary>
/// Промпт классификатора входящих
/// </summary>
public string AiPrompt { get; init; } = string.Empty;
/// <summary>
/// Промпт ИИ-фильтра входящих.
/// </summary>
public string AiFilterPrompt { get; init; } = string.Empty;
/// <summary>
/// Промпт структуры карточки
/// </summary>
public string CardPrompt { get; init; } = string.Empty;
/// <summary>
/// Тип собираемых заявок
/// </summary>
public string WantedType { get; init; } = string.Empty;
/// <summary>
/// Без указания суммы не создавать карточку найма.
/// </summary>
public bool BudgetRequiredHire { get; init; }
/// <summary>
/// Без указания суммы не создавать карточку заказа.
/// </summary>
public bool BudgetRequiredOrder { get; init; }
/// <summary>
/// Подпись найма на карточке.
/// </summary>
public string HireLabel { get; init; } = string.Empty;
/// <summary>
/// Подпись разового заказа на карточке.
/// </summary>
public string OrderLabel { get; init; } = string.Empty;
/// <summary>
/// Описание сферы
/// </summary>
public string DomainDescription { get; init; } = string.Empty;
/// <summary>
/// Общие слова-маркеры заявок сферы.
/// </summary>
public IReadOnlyList<string> DomainKeywords { get; init; } = Array.Empty<string>();
/// <summary>
/// Маркеры найма
/// </summary>
public IReadOnlyList<string> HireMarkers { get; init; } = Array.Empty<string>();
/// <summary>
/// Термины грейдов/уровней.
/// </summary>
public IReadOnlyList<string> LevelTerms { get; init; } = Array.Empty<string>();
/// <summary>
/// Маркеры резюме соискателей.
/// </summary>
public IReadOnlyList<string> ResumeMarkers { get; init; } = Array.Empty<string>();
/// <summary>
/// Отсекать резюме соискателей на.
/// </summary>
public bool BlockResumes { get; init; }
/// <summary>
/// Глобальные исключения по ключевым словам/фразам/технологиям
/// </summary>
public IReadOnlyList<string> ExcludeKeywords { get; init; } = Array.Empty<string>();
/// <summary>
/// Глобальные исключения по локации/языку
/// </summary>
public IReadOnlyList<string> ExcludeLocations { get; init; } = Array.Empty<string>();
/// <summary>
/// Глобальные исключения по типу заявки
/// </summary>
public IReadOnlyList<string> ExcludeTypes { get; init; } = Array.Empty<string>();
/// <summary>
/// Нижняя граница глобального исключения по бюджету
/// </summary>
public int ExcludeBudgetFrom { get; init; }
/// <summary>
/// Верхняя граница глобального исключения по бюджету
/// </summary>
public int ExcludeBudgetTo { get; init; }
/// <summary>
/// Личная библиотека промптов пользователя.
/// </summary>
public IReadOnlyList<MyPromptDto> MyPrompts { get; init; } = Array.Empty<MyPromptDto>();
/// <summary>
/// Общие напоминания включены.
/// </summary>
public bool RemindersEnabled { get; init; }
/// <summary>
/// Конвертация бюджетов карточек включена.
/// </summary>
public bool ConversionOn { get; init; }
/// <summary>
/// Целевая валюта конвертации.
/// </summary>
public string TargetCurrency { get; init; } = string.Empty;
/// <summary>
/// Источник курсов
/// </summary>
public string RateSource { get; init; } = string.Empty;
/// <summary>
/// Авто-мониторинг новых чатов/каналов.
/// </summary>
public bool AutoMonitorNew { get; init; }
/// <summary>
/// Суточный лимит авто-вступлений Discovery
/// </summary>
public int DiscJoinLimit { get; init; }
/// <summary>
/// Нижняя граница паузы между авто-вступлениями, сек
/// </summary>
public int DiscJoinDelayMin { get; init; }
/// <summary>
/// Верхняя граница паузы между авто-вступлениями, сек
/// </summary>
public int DiscJoinDelayMax { get; init; }
/// <summary>
/// Размер выборки сообщений при оценке канала
/// </summary>
public int DiscEvalSample { get; init; }
/// <summary>
/// Процент подходящих сообщений для оценки канала
/// </summary>
public int DiscEvalThreshold { get; init; }
/// <summary>
/// Стоп-кран авто-вступлений Discovery.
/// </summary>
public bool DiscPaused { get; init; }
/// <summary>
/// Состояние колонок канбана.
/// </summary>
public IReadOnlyDictionary<string, object?> ColState { get; init; } =
new Dictionary<string, object?>();
/// <summary>
/// Активный AI-провайдер
/// </summary>
public string AiProvider { get; init; } = string.Empty;
/// <summary>
/// Публичные конфигурации AI-провайдеров
/// </summary>
public IReadOnlyDictionary<string, AiConfigPublicDto> AiConfigs { get; init; } =
new Dictionary<string, AiConfigPublicDto>();
/// <summary>
/// Статический список AI-провайдеров.
/// </summary>
public IReadOnlyList<ProviderPublicDto> Providers { get; init; } = Array.Empty<ProviderPublicDto>();
}
@@ -0,0 +1,12 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Значение внутреннего KV-ключа <c>ratesCache</c>.
/// </summary>
/// <param name="Rates">Курсы к рублю: «код валюты → курс» (включая RUB:1).</param>
/// <param name="Source">Источник сохранённых курсов: <c>mock</c> | <c>cbr</c>.</param>
/// <param name="UpdatedAtMs">Момент сохранения кэша (Unix-ms, UTC).</param>
public sealed record RatesCacheValue(
IReadOnlyDictionary<string, double> Rates,
string Source,
long UpdatedAtMs);
@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Курсы валют — тело ответа GET /api/rates и поле <c>rates</c> POST /api/rates/refresh.
/// </summary>
/// <param name="Base">Базовая валюта (RUB).</param>
/// <param name="Rates">Курсы к базовой: «код валюты → сколько базовой за 1 единицу».</param>
/// <param name="Source">Источник кэша: <c>mock</c> | <c>cbr</c>.</param>
/// <param name="UpdatedAtMs">Момент последнего обновления кэша (Unix-ms, UTC) или null, если кэша ещё нет.</param>
public sealed record RatesDto(
string Base,
IReadOnlyDictionary<string, double> Rates,
string Source,
[property: JsonPropertyName("updatedAt")] long? UpdatedAtMs);
@@ -0,0 +1,47 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Категория ключа настроек тенанта
/// </summary>
public enum SettingKind
{
/// <summary>
/// Целое число (например, <c>archiveAfterDays</c>, <c>minLen</c>).
/// </summary>
Int,
/// <summary>
/// Булево (например, <c>autoArchive</c>, <c>aiEnabled</c>).
/// </summary>
Bool,
/// <summary>
/// Строка (например, <c>targetCurrency</c>, <c>aiPrompt</c>).
/// </summary>
String,
/// <summary>
/// Список строк
/// </summary>
List,
/// <summary>
/// Произвольный JSON-объект
/// </summary>
Dict,
/// <summary>
/// Личная библиотека промптов пользователя
/// </summary>
MyPrompts,
/// <summary>
/// Конфигурации AI-провайдеров с ключами
/// </summary>
AiConfigs,
/// <summary>
/// Внутренний (непубличный) ключ
/// </summary>
Internal,
}
@@ -0,0 +1,9 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Значение настройки из KV-хранилища
/// </summary>
/// <param name="Key">Ключ настройки (имя из каталога <c>SettingsKeys</c>).</param>
/// <param name="ValueJson">Значение, сериализованное в JSON.</param>
/// <param name="UpdatedAt">Момент последней записи (UTC).</param>
public sealed record SettingValue(string Key, string ValueJson, DateTimeOffset UpdatedAt);
@@ -0,0 +1,270 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Дефолтные значения настроек тенанта.
/// </summary>
public static class SettingsDefaults
{
// ── Хранилище ──
/// <summary>
/// Дефолт «autoArchive»
/// </summary>
public const bool AutoArchive = true;
/// <summary>
/// Дефолт «archiveAfterDays»
/// </summary>
public const int ArchiveAfterDays = 14;
/// <summary>
/// Дефолт «archiveClearDays»
/// </summary>
public const int ArchiveClearDays = 90;
/// <summary>
/// Дефолт «trashClearDays»
/// </summary>
public const int TrashClearDays = 7;
// ── Фильтры входящих ──
/// <summary>
/// Дефолт «minLen»
/// </summary>
public const int MinLen = 24;
/// <summary>
/// Дефолтные стоп-фразы.
/// </summary>
public static readonly IReadOnlyList<string> StopPhrases = new[]
{
"взаимный пиар",
"резюме",
"ищу работу",
"набор в команду",
};
/// <summary>
/// Дефолт «mlEnabled»
/// </summary>
public const bool MlEnabled = true;
/// <summary>
/// Дефолт «aiEnabled»
/// </summary>
public const bool AiEnabled = true;
/// <summary>
/// Дефолт «aiFilterEnabled»
/// </summary>
public const bool AiFilterEnabled = true;
/// <summary>
/// Дефолт «aiPrompt» — текст из DefaultPrompts.
/// </summary>
public static readonly string AiPrompt = DefaultPrompts.DefaultAiPrompt;
/// <summary>
/// Дефолт «aiFilterPrompt» — текст из DefaultPrompts.
/// </summary>
public static readonly string AiFilterPrompt = DefaultPrompts.DefaultAiFilterPrompt;
/// <summary>
/// Дефолт « » — текст из DefaultPrompts.
/// </summary>
public static readonly string CardPrompt = DefaultPrompts.DefaultCardPrompt;
// ── Сфера и ключи ──
/// <summary>
/// Дефолт «domainDescription»
/// </summary>
public const string DomainDescription = "";
/// <summary>
/// Дефолт «domainKeywords»
/// </summary>
public static readonly IReadOnlyList<string> DomainKeywords = Array.Empty<string>();
/// <summary>
/// Дефолтные маркеры найма.
/// </summary>
public static readonly IReadOnlyList<string> HireMarkers = new[]
{
"вакансия", "вакансию", "вакансии", "вакантна", "вакант", "нанимаем", "найм", "full-time",
"на постоянную", "занятость", "в офис", "официальное оформление", "пятидневка",
"грейд", "в компанию", "полная занятость", "на постоянную работу", "в команду", "нанимает",
"на постоянную основу", "в штат",
};
/// <summary>
/// Дефолтные термины грейдов/уровней.
/// </summary>
public static readonly IReadOnlyList<string> LevelTerms = new[]
{
"junior", "джун", "джуниор", "middle", "мидл", "mid", "senior", "сеньор", "сеньйор",
"lead", "тимлид", "тиэмлид", "architect", "архитектор", "стажёр", "стажер", "intern", "trainee",
};
/// <summary>
/// Дефолтные маркеры резюме соискателей.
/// </summary>
public static readonly IReadOnlyList<string> ResumeMarkers = new[]
{
"резюме", "#резюме", "моё резюме", "мое резюме", "ищу работу", "ищу вакансию",
"рассмотрю предложения", "готов к собеседованию", "в поиске работы",
"ищу проект", "ищу подработку",
};
/// <summary>
/// Дефолт «blockResumes»
/// </summary>
public const bool BlockResumes = true;
/// <summary>
/// Дефолт «excludeKeywords»
/// </summary>
public static readonly IReadOnlyList<string> ExcludeKeywords = Array.Empty<string>();
/// <summary>
/// Дефолт «excludeLocations»
/// </summary>
public static readonly IReadOnlyList<string> ExcludeLocations = Array.Empty<string>();
/// <summary>
/// Дефолт «excludeTypes»
/// </summary>
public static readonly IReadOnlyList<string> ExcludeTypes = Array.Empty<string>();
/// <summary>
/// Дефолт «excludeBudgetFrom»
/// </summary>
public const int ExcludeBudgetFrom = 0;
/// <summary>
/// Дефолт «excludeBudgetTo»
/// </summary>
public const int ExcludeBudgetTo = 0;
/// <summary>
/// Дефолт «wantedType»
/// </summary>
public const string WantedType = "both";
/// <summary>
/// Дефолт «budgetRequiredHire»
/// </summary>
public const bool BudgetRequiredHire = false;
/// <summary>
/// Дефолт «budgetRequiredOrder»
/// </summary>
public const bool BudgetRequiredOrder = false;
/// <summary>
/// Дефолт «hireLabel»
/// </summary>
public const string HireLabel = "вакансия";
/// <summary>
/// Дефолт «orderLabel»
/// </summary>
public const string OrderLabel = "фриланс";
// ── Мои промпты / напоминания ──
/// <summary>
/// Дефолт «myPrompts»
/// </summary>
public static readonly IReadOnlyList<MyPromptDto> MyPrompts = Array.Empty<MyPromptDto>();
/// <summary>
/// Дефолт «remindersEnabled»
/// </summary>
public const bool RemindersEnabled = true;
// ── Валюта и курсы ──
/// <summary>
/// Дефолт «conversionOn»
/// </summary>
public const bool ConversionOn = true;
/// <summary>
/// Дефолт «targetCurrency»
/// </summary>
public const string TargetCurrency = "RUB";
/// <summary>
/// Дефолт «rateSource»
/// </summary>
public const string RateSource = "cbr";
/// <summary>
/// Дефолт «colState»
/// </summary>
public static readonly IReadOnlyDictionary<string, object?> ColState = new Dictionary<string, object?>();
// ── ИИ ──
/// <summary>
/// Дефолт «aiProvider»
/// </summary>
public const string AiProvider = "deepseek";
/// <summary>
/// Дефолт «aiConfigs»
/// </summary>
public static readonly IReadOnlyDictionary<string, AiConfigSetting> AiConfigs = BuildDefaultAiConfigs();
// ── Telegram / Discovery ──
/// <summary>
/// Дефолт «autoMonitorNew»
/// </summary>
public const bool AutoMonitorNew = true;
/// <summary>
/// Дефолт «discJoinLimit»
/// </summary>
public const int DiscJoinLimit = 50;
/// <summary>
/// Дефолт «discJoinDelayMin»
/// </summary>
public const int DiscJoinDelayMin = 50;
/// <summary>
/// Дефолт «discJoinDelayMax»
/// </summary>
public const int DiscJoinDelayMax = 70;
/// <summary>
/// Дефолт «discEvalSample»
/// </summary>
public const int DiscEvalSample = 10;
/// <summary>
/// Дефолт «discEvalThreshold»
/// </summary>
public const int DiscEvalThreshold = 40;
/// <summary>
/// Дефолт «discPaused»
/// </summary>
public const bool DiscPaused = false;
private static IReadOnlyDictionary<string, AiConfigSetting> BuildDefaultAiConfigs()
{
var result = new Dictionary<string, AiConfigSetting>(AiProviders.All.Count);
foreach (AiProviderDefinition provider in AiProviders.All)
{
string firstModel = provider.Models.FirstOrDefault() ?? string.Empty;
result.Add(provider.Id, new AiConfigSetting(ApiKey: "", BaseUrl: provider.Base, Model: firstModel));
}
return result;
}
}
@@ -0,0 +1,217 @@
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Каталог ключей настроек тенанта.
/// </summary>
public static class SettingsKeys
{
// ── Внутренние (непубличные) ключи: в GET/PATCH /settings не участвуют ──
/// <summary>
/// Кэш курсов валют.
/// </summary>
public const string RatesCache = "ratesCache";
/// <summary>
/// Счётчик решений ML.
/// </summary>
public const string MlDecisions = "mlDecisions";
/// <summary>
/// Счётчик решений ИИ.
/// </summary>
public const string AiDecisions = "aiDecisions";
/// <summary>
/// Метка последнего успешного ИИ-предложения колонок, epoch-сек.
/// </summary>
public const string LastSuggestAt = "lastSuggestAt";
/// <summary>
/// Последний статус Telegram-аккаунта.
/// </summary>
public const string TgStatus = "tgStatus";
/// <summary>
/// Аккаунт Telegram «@username».
/// </summary>
public const string TgAccount = "tgAccount";
/// <summary>
/// Накопленный учёт токенов ИИ-вызовов.
/// </summary>
public const string AiTokenUsage = "aiTokenUsage";
/// <summary>
/// День суточного лимита авто-вступлений Discovery «flood».
/// </summary>
public const string DiscFloodDay = "discFloodDay";
// ── Int: целочисленные настройки ──
public const string ArchiveAfterDays = "archiveAfterDays";
public const string ArchiveClearDays = "archiveClearDays";
public const string TrashClearDays = "trashClearDays";
/// <summary>
/// Минимальная длина сообщения для -1 фильтра
/// </summary>
public const string MinLen = "minLen";
public const string DiscJoinLimit = "discJoinLimit";
public const string DiscJoinDelayMin = "discJoinDelayMin";
public const string DiscJoinDelayMax = "discJoinDelayMax";
public const string DiscEvalSample = "discEvalSample";
public const string DiscEvalThreshold = "discEvalThreshold";
public const string ExcludeBudgetFrom = "excludeBudgetFrom";
public const string ExcludeBudgetTo = "excludeBudgetTo";
// ── Bool: булевы настройки ──
public const string AutoArchive = "autoArchive";
public const string AiEnabled = "aiEnabled";
public const string AiFilterEnabled = "aiFilterEnabled";
public const string ConversionOn = "conversionOn";
public const string RemindersEnabled = "remindersEnabled";
public const string MlEnabled = "mlEnabled";
public const string BlockResumes = "blockResumes";
public const string BudgetRequiredHire = "budgetRequiredHire";
public const string BudgetRequiredOrder = "budgetRequiredOrder";
public const string AutoMonitorNew = "autoMonitorNew";
public const string DiscPaused = "discPaused";
// ── String: строковые настройки ──
public const string TargetCurrency = "targetCurrency";
public const string RateSource = "rateSource";
public const string AiProvider = "aiProvider";
public const string AiPrompt = "aiPrompt";
public const string AiFilterPrompt = "aiFilterPrompt";
/// <summary>
/// Ключ « »: промпт структуры карточки
/// </summary>
public const string CardPrompt = "cardPrompt";
public const string DomainDescription = "domainDescription";
public const string WantedType = "wantedType";
public const string HireLabel = "hireLabel";
public const string OrderLabel = "orderLabel";
// ── List: списки строк ──
public const string StopPhrases = "stopPhrases";
public const string DomainKeywords = "domainKeywords";
public const string HireMarkers = "hireMarkers";
public const string LevelTerms = "levelTerms";
public const string ResumeMarkers = "resumeMarkers";
public const string ExcludeKeywords = "excludeKeywords";
public const string ExcludeLocations = "excludeLocations";
public const string ExcludeTypes = "excludeTypes";
// ── Словари / особые ──
public const string ColState = "colState";
public const string MyPrompts = "myPrompts";
public const string AiConfigs = "aiConfigs";
/// <summary>
/// Каталог публичных ключей
/// </summary>
public static readonly IReadOnlyDictionary<string, SettingKind> PublicKeys = new Dictionary<string, SettingKind>
{
// Int
[ArchiveAfterDays] = SettingKind.Int,
[ArchiveClearDays] = SettingKind.Int,
[TrashClearDays] = SettingKind.Int,
[MinLen] = SettingKind.Int,
[DiscJoinLimit] = SettingKind.Int,
[DiscJoinDelayMin] = SettingKind.Int,
[DiscJoinDelayMax] = SettingKind.Int,
[DiscEvalSample] = SettingKind.Int,
[DiscEvalThreshold] = SettingKind.Int,
[ExcludeBudgetFrom] = SettingKind.Int,
[ExcludeBudgetTo] = SettingKind.Int,
// Bool
[AutoArchive] = SettingKind.Bool,
[AiEnabled] = SettingKind.Bool,
[AiFilterEnabled] = SettingKind.Bool,
[ConversionOn] = SettingKind.Bool,
[RemindersEnabled] = SettingKind.Bool,
[MlEnabled] = SettingKind.Bool,
[BlockResumes] = SettingKind.Bool,
[BudgetRequiredHire] = SettingKind.Bool,
[BudgetRequiredOrder] = SettingKind.Bool,
[AutoMonitorNew] = SettingKind.Bool,
[DiscPaused] = SettingKind.Bool,
// String
[TargetCurrency] = SettingKind.String,
[RateSource] = SettingKind.String,
[AiProvider] = SettingKind.String,
[AiPrompt] = SettingKind.String,
[AiFilterPrompt] = SettingKind.String,
[CardPrompt] = SettingKind.String,
[DomainDescription] = SettingKind.String,
[WantedType] = SettingKind.String,
[HireLabel] = SettingKind.String,
[OrderLabel] = SettingKind.String,
// List
[StopPhrases] = SettingKind.List,
[DomainKeywords] = SettingKind.List,
[HireMarkers] = SettingKind.List,
[LevelTerms] = SettingKind.List,
[ResumeMarkers] = SettingKind.List,
[ExcludeKeywords] = SettingKind.List,
[ExcludeLocations] = SettingKind.List,
[ExcludeTypes] = SettingKind.List,
// Dict
[ColState] = SettingKind.Dict,
// special
[MyPrompts] = SettingKind.MyPrompts,
[AiConfigs] = SettingKind.AiConfigs,
};
/// <summary>
/// Возвращает категорию публичного ключа или null, если ключ не в каталоге.
/// </summary>
/// <param name="key">Имя ключа.</param>
/// <returns>Категория ключа или null для неизвестного/внутреннего ключа.</returns>
public static SettingKind? FindPublicKind(string key)
{
return PublicKeys.TryGetValue(key, out SettingKind kind) ? kind : null;
}
}
@@ -0,0 +1,227 @@
using System.Globalization;
using System.Text.Json;
using Deal.Modules.Settings.Application.Abstractions;
namespace Deal.Modules.Settings.Application.Models;
/// <summary>
/// Типизированный снимок настроек тенанта
/// </summary>
public sealed class TenantSettingsSnapshot
{
private static readonly JsonSerializerOptions RatesCacheJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
// Переопределения тенанта: ключ → строка settings (копия на момент загрузки).
private readonly IReadOnlyDictionary<string, SettingValue> _overrides;
// Создаёт снимок из готовых строк (конструктор приватный — вход через LoadAsync/FromRows).
// overrides: Переопределения настроек тенанта (ключ → строка).
private TenantSettingsSnapshot(IReadOnlyDictionary<string, SettingValue> overrides)
{
_overrides = overrides;
}
/// <summary>
/// Читает ВСЕ переопределения тенанта одним запросом и строит снимок.
/// </summary>
/// <param name="store">KV-хранилище настроек тенанта (таблица settings).</param>
/// <returns>Снимок (значения резолвятся лениво методами Get* с дефолтами SettingsDefaults).</returns>
public static async Task<TenantSettingsSnapshot> LoadAsync(ISettingsStore store, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(store);
IReadOnlyCollection<SettingValue> rows = await store.GetAllAsync(ct);
return FromRows(rows);
}
/// <summary>
/// Строит снимок из готовых строк — переиспользование одного чтения на операцию/батч.
/// </summary>
/// <param name="rows">Строки настроек (например, из одного GetAllAsync вызывающего).</param>
/// <returns>Снимок над переданными строками.</returns>
public static TenantSettingsSnapshot FromRows(IEnumerable<SettingValue> rows)
{
ArgumentNullException.ThrowIfNull(rows);
var map = new Dictionary<string, SettingValue>(StringComparer.Ordinal);
foreach (SettingValue row in rows)
{
map[row.Key] = row;
}
return new TenantSettingsSnapshot(map);
}
/// <summary>
/// Булева настройка
/// </summary>
/// <param name="key">Ключ настройки (bool-категория каталога <see cref="SettingsKeys"/>).</param>
/// <param name="defaultValue">Дефолт из <see cref="SettingsDefaults"/>.</param>
/// <returns>Значение настройки либо дефолт (отсутствие строки/повреждение/другой JSON-вид).</returns>
public bool GetBool(string key, bool defaultValue)
{
JsonElement? value = ReadValue(key);
if (value is { ValueKind: JsonValueKind.True })
{
return true;
}
if (value is { ValueKind: JsonValueKind.False })
{
return false;
}
return defaultValue;
}
/// <summary>
/// Целочисленная настройка
/// </summary>
/// <param name="key">Ключ настройки (int-категория каталога <see cref="SettingsKeys"/>).</param>
/// <param name="defaultValue">Дефолт из <see cref="SettingsDefaults"/>.</param>
/// <returns>Значение настройки (диапазон long сжимается до int) либо дефолт.</returns>
public int GetInt(string key, int defaultValue)
{
return TryReadInt64(key, out long wide)
? (int)Math.Clamp(wide, int.MinValue, int.MaxValue)
: defaultValue;
}
/// <summary>
/// Длинная целочисленная настройка
/// </summary>
/// <param name="key">Ключ настройки (числовой JSON).</param>
/// <param name="defaultValue">Дефолт (0 для внутренних счётчиков).</param>
/// <returns>Значение настройки либо дефолт.</returns>
public long GetLong(string key, long defaultValue)
{
return TryReadInt64(key, out long wide) ? wide : defaultValue;
}
/// <summary>
/// Строковая настройка
/// </summary>
/// <param name="key">Ключ настройки (string-категория каталога <see cref="SettingsKeys"/>).</param>
/// <param name="defaultValue">Дефолт из <see cref="SettingsDefaults"/>.</param>
/// <returns>Значение настройки либо дефолт.</returns>
public string GetString(string key, string defaultValue)
{
JsonElement? value = ReadValue(key);
if (value is { ValueKind: JsonValueKind.String })
{
return value.Value.GetString() ?? defaultValue;
}
return defaultValue;
}
/// <summary>
/// Список-настройка строк
/// </summary>
/// <param name="key">Ключ настройки (list-категория каталога <see cref="SettingsKeys"/>).</param>
/// <param name="defaultValue">Дефолт из <see cref="SettingsDefaults"/>.</param>
/// <returns>Список строк либо дефолт.</returns>
public IReadOnlyList<string> GetStringList(string key, IReadOnlyList<string> defaultValue)
{
JsonElement? value = ReadValue(key);
if (value is null)
{
return defaultValue;
}
switch (value.Value.ValueKind)
{
case JsonValueKind.Array:
var items = new List<string>();
foreach (JsonElement element in value.Value.EnumerateArray())
{
if (element.ValueKind == JsonValueKind.String)
{
items.Add(element.GetString() ?? string.Empty);
}
}
return items;
case JsonValueKind.String:
return new[] { value.Value.GetString() ?? string.Empty };
default:
return defaultValue;
}
}
/// <summary>
/// Внутренний кэш курсов ratesCache
/// </summary>
/// <returns>Кэш {rates, source, updatedAtMs} либо null — строки нет / JSON повреждён / rates не задан (дефолт — мок-курсы, решает вызывающий, как RatesService.LoadCacheAsync).</returns>
public RatesCacheValue? TryGetRatesCache()
{
JsonElement? value = ReadValue(SettingsKeys.RatesCache);
if (value is null)
{
return null;
}
try
{
RatesCacheValue? cache = value.Value.Deserialize<RatesCacheValue>(RatesCacheJsonOptions);
return cache is { Rates: not null } ? cache : null;
}
catch (JsonException)
{
// Повреждённый кэш трактуем как отсутствующий (мягкая семантика, как в SettingsService).
return null;
}
}
// Читает целочисленное значение настройки (JSON-число или числовая строка).
// key: Ключ настройки.
// value: Прочитанное число (int64; диапазон шире int32 — сжимает вызывающий).
// Возвращает: True — значение прочитано; False — отсутствует/повреждено/нечисловое.
private bool TryReadInt64(string key, out long value)
{
JsonElement? element = ReadValue(key);
if (element is null)
{
value = 0;
return false;
}
if (element.Value.ValueKind == JsonValueKind.Number)
{
return element.Value.TryGetInt64(out value);
}
if (element.Value.ValueKind == JsonValueKind.String)
{
return long.TryParse(
element.Value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out value);
}
value = 0;
return false;
}
// Распарсенное JSON-значение сохранённой настройки; отсутствие строки/повреждённый JSON → null.
// key: Ключ настройки.
// Возвращает: Корневой JsonElement (копия, живёт после вызова) или null.
private JsonElement? ReadValue(string key)
{
if (!_overrides.TryGetValue(key, out SettingValue? row))
{
return null;
}
try
{
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
return document.RootElement.Clone();
}
catch (JsonException)
{
// Повреждённая строка — трактуем как отсутствующую (мягкая семантика, как в SettingsService).
return null;
}
}
}