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,16 @@
using Deal.Modules.Settings.Application.Models;
namespace Deal.Modules.Settings.Application.Abstractions;
/// <summary>
/// Порт проверки подключения к выбранному AI-провайдеру.
/// </summary>
public interface IAiConnectionChecker
{
/// <summary>
/// Проверяет соединение с провайдером по активной конфигурации тенанта.
/// </summary>
/// <param name="request">Данные проверки: id провайдера из каталога, эффективный base/model, расшифрованный ключ.</param>
/// <returns>Результат: {ok, message} + статус провайдера.</returns>
public Task<AiCheckResultDto> CheckAsync(AiCheckRequest request, CancellationToken ct);
}
@@ -0,0 +1,26 @@
using Deal.Modules.Settings.Application.Models;
namespace Deal.Modules.Settings.Application.Abstractions;
/// <summary>
/// Порт KV-хранилища глобальных
/// </summary>
public interface IGlobalSettingsStore
{
/// <summary>
/// Читает одно глобальное значение по ключу
/// </summary>
/// <param name="key">Ключ глобальной настройки.</param>
/// <returns>Значение или null, если строка отсутствует (настройка не задана оператором).</returns>
public Task<SettingValue?> GetAsync(string key, CancellationToken ct);
/// <summary>
/// Записывает (создаёт или обновляет) глобальное значение; UpdatedAt — UTC-now.
/// </summary>
/// <param name="key">Ключ глобальной настройки.</param>
/// <param name="valueJson">Значение, сериализованное в JSON.</param>
public Task SetAsync(
string key,
string valueJson,
CancellationToken ct);
}
@@ -0,0 +1,15 @@
using Deal.Modules.Settings.Application.Services;
namespace Deal.Modules.Settings.Application.Abstractions;
/// <summary>
/// Порт модуля Settings
/// </summary>
public interface IRatesChangedListener
{
/// <summary>
/// Курсы/настройки конверсии изменились — пересчитать конверсии бюджетов карточек.
/// </summary>
/// <param name="fullRecompute">True — полный пересчёт всех кандидатов (оба текущих триггера). Параметр зарезервирован для будущих частичных событий (пересчёт одной карточки).</param>
public Task OnRatesChangedAsync(bool fullRecompute, CancellationToken ct);
}
@@ -0,0 +1,15 @@
using Deal.Modules.Settings.Application.Services;
namespace Deal.Modules.Settings.Application.Abstractions;
/// <summary>
/// Порт источника курсов валют к рублю.
/// </summary>
public interface IRatesSource
{
/// <summary>
/// Запрашивает актуальные курсы к рублю.
/// </summary>
/// <returns>Словарь «код валюты (RUB/USD/EUR/…) → курс к RUB» или null при сбое.</returns>
public Task<Dictionary<string, double>?> FetchAsync(CancellationToken ct);
}
@@ -0,0 +1,21 @@
namespace Deal.Modules.Settings.Application.Abstractions;
/// <summary>
/// Порт симметричного шифрования секретов тенанта
/// </summary>
public interface ISecretCipher
{
/// <summary>
/// Шифрует открытый текст в формат <c>enc:</c> + Base64(nonce ‖ шифротекст ‖ tag).
/// </summary>
/// <param name="plainText">Открытый текст (UTF-8).</param>
/// <returns>Зашифрованное значение с префиксом <c>enc:</c>; для пустой строки — пустая строка.</returns>
public string Encrypt(string plainText);
/// <summary>
/// Расшифровывает значение формата <c>enc:</c> + Base64(nonce ‖ шифротекст ‖ tag).
/// </summary>
/// <param name="cipherText">Значение из хранилища.</param>
/// <returns>Открытый текст. Пустая строка (без исключений) для пустого входа, значения без префикса <c>enc:</c> и повреждённого/зашифрованного чужим ключом значения.</returns>
public string Decrypt(string cipherText);
}
@@ -0,0 +1,38 @@
using Deal.Modules.Settings.Application.Models;
namespace Deal.Modules.Settings.Application.Abstractions;
/// <summary>
/// Порт KV-хранилища настроек тенанта
/// </summary>
public interface ISettingsStore
{
/// <summary>
/// Читает одно значение по ключу.
/// </summary>
/// <param name="key">Ключ настройки (см. <see cref="SettingsKeys"/>).</param>
/// <returns>Значение или null, если строка отсутствует (значит — дефолт из кода).</returns>
public Task<SettingValue?> GetAsync(string key, CancellationToken ct);
/// <summary>
/// Читает все сохранённые значения
/// </summary>
/// <returns>Все строки настроек тенанта.</returns>
public Task<IReadOnlyCollection<SettingValue>> GetAllAsync(CancellationToken ct);
/// <summary>
/// Записывает (создаёт или обновляет) значение; updated_at — UTC-now.
/// </summary>
/// <param name="key">Ключ настройки.</param>
/// <param name="valueJson">Значение, сериализованное в JSON.</param>
public Task SetAsync(
string key,
string valueJson,
CancellationToken ct);
/// <summary>
/// Удаляет значение по ключу
/// </summary>
/// <param name="key">Ключ настройки.</param>
public Task RemoveAsync(string key, CancellationToken ct);
}
@@ -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 короткий заголовок (49 слов, без эмодзи, хэштегов, markdown-разметки, ссылок и знаков препинания в конце)
5. company кто разместил заявку: компания/бренд/частное лицо/заказчик. Только факт из текста; не указано верни ""
6. format формат работы/выполнения: удалённо/офис/гибрид, город, график. Коротко; нет ""
7. task 12 предложения: что за задача/роль и в чём суть (для кого, что нужно сделать). Ёмко, без пересказа всего объявления и служебных строк
8. requirements JSON-массив строк ключевых требований к кандидату/исполнителю (пункты «Требования/Обязанности/Нужно»). Нет []
9. plus JSON-массив строк «будет плюсом»/«приветствуется»/«желательно». Нет []
10. conditions условия одной строкой: оплата/ЗП/вилка, сроки, объём, тип занятости. Нет ""
11. stack JSON-массив строк (не строка!): технологии/предметы/услуги/материалы из заявки. Названия пиши слитно как в оригинале: ".NET", "C#", "Node.js", "ASP.NET Core" не разбивай на отдельные буквы (28)
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/). НЕ включай ботов, каналы/группы и ссылки на вакансию/пост/форму. Если контакта в тексте нет пустой массив []
Поля 510 это блок «О заявке» на карточке: заполняй их только фактами из текста, ничего не выдумывай. Запрещено: 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 12 предложения по шаблону: что за задача/роль для кого что нужно сделать/какой результат. Пиши ёмко, не пересказывай объявление дословно.
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;
}
}
}
@@ -0,0 +1,22 @@
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Services;
using Microsoft.Extensions.DependencyInjection;
namespace Deal.Modules.Settings.Application.Registrars;
/// <summary>
/// DI-регистрация модуля Settings.
/// </summary>
public static class SettingsModuleRegistrar
{
/// <summary>
/// Регистрирует сервисы модуля Settings в контейнере.
/// </summary>
public static IServiceCollection AddSettingsModule(this IServiceCollection services)
{
services.AddScoped<SettingsService>();
services.AddScoped<RatesService>();
services.AddScoped<IncomingRules>();
return services;
}
}
@@ -0,0 +1,27 @@
namespace Deal.Modules.Settings.Application.Services;
/// <summary>
/// Константы курсов валют
/// </summary>
public static class MockRates
{
/// <summary>
/// Мок-курсы к рублю
/// </summary>
public static readonly IReadOnlyDictionary<string, double> Values = new Dictionary<string, double>
{
["RUB"] = 1.0,
["USD"] = 92.5,
["EUR"] = 99.9,
["CNY"] = 13.1,
["KZT"] = 0.19,
["BYN"] = 28.6,
["USDT"] = 92.5,
["GBP"] = 117.4,
};
/// <summary>
/// Интервал обновления кэша курсов
/// </summary>
public static readonly TimeSpan RatesFetchInterval = TimeSpan.FromHours(6);
}
@@ -0,0 +1,74 @@
namespace Deal.Modules.Settings.Application.Services;
/// <summary>
/// Подстановка плейсхолдеров <c>{domain}</c>/<c>{keywords}</c> в текст промпта
/// </summary>
public static class PromptFiller
{
/// <summary>
/// Фраза-фолбэк сферы, когда <c>domainDescription</c> не задан.
/// </summary>
public const string FallbackDomain =
"Универсально: заявка = конкретный запрос на услугу/товар/работу или найм человека.";
/// <summary>
/// Подсказка вместо списка ключевых слов, когда <c>domainKeywords</c> пуст.
/// </summary>
public const string NoKeywordsHint = "(не заданы — определяй по тексту)";
/// <summary>
/// Максимум ключевых слов в подстановке.
/// </summary>
public const int MaxKeywords = 60;
/// <summary>
/// Заменяет в тексте промпта <c>{domain}</c> и <c>{keywords}</c> значениями сферы и слов-маркеров.
/// </summary>
/// <param name="prompt">Текст промпта (может не содержать плейсхолдеров); пустой/нулевой → пустая строка.</param>
/// <param name="domain">Описание сферы; пустое или из пробелов → <see cref="FallbackDomain"/>.</param>
/// <param name="keywords">Слова-маркеры заявок; пробельные элементы отбрасываются, склейка через «, », срез — первые <see cref="MaxKeywords"/>.</param>
/// <returns>Текст с подставленными значениями.</returns>
public static string Fill(
string? prompt,
string? domain,
IReadOnlyCollection<string>? keywords)
{
if (string.IsNullOrEmpty(prompt))
{
return string.Empty;
}
string domainText = string.IsNullOrWhiteSpace(domain) ? FallbackDomain : domain.Trim();
string keywordsText = BuildKeywordsText(keywords);
return prompt.Replace("{domain}", domainText, StringComparison.Ordinal)
.Replace("{keywords}", keywordsText, StringComparison.Ordinal);
}
// Склеивает слова-маркеры в строку «a, b, c» (не больше MaxKeywords).
// keywords: Исходные слова; могут содержать пустые/пробельные элементы.
// Возвращает: Список через «, » или NoKeywordsHint, если слов нет.
private static string BuildKeywordsText(IReadOnlyCollection<string>? keywords)
{
if (keywords is null || keywords.Count == 0)
{
return NoKeywordsHint;
}
var taken = new List<string>(Math.Min(keywords.Count, MaxKeywords));
foreach (string? raw in keywords)
{
if (taken.Count == MaxKeywords)
{
break;
}
string keyword = raw?.Trim() ?? string.Empty;
if (keyword.Length > 0)
{
taken.Add(keyword);
}
}
return taken.Count == 0 ? NoKeywordsHint : string.Join(", ", taken);
}
}
@@ -0,0 +1,170 @@
using System.Text.Json;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Modules.Settings.Application.Services;
/// <summary>
/// Курсы валют: кэш в tenant-настройке <c>ratesCache</c>, источник по <c>rateSource</c>.
/// </summary>
/// <param name="store">KV-хранилище настроек тенанта (таблица settings).</param>
/// <param name="ratesSource">Порт источника курсов (ЦБ РФ); не используется в мок-режиме.</param>
/// <param name="listeners">Слушатели смены курсов; пусто — no-op.</param>
public sealed class RatesService(
ISettingsStore store,
IRatesSource ratesSource,
IEnumerable<IRatesChangedListener> listeners)
{
private const string BaseCurrency = "RUB";
// Source кэша для мок-курсов.
private const string MockSource = "mock";
// Source кэша после успешного запроса к ЦБ.
private const string CbrSource = "cbr";
// Значение настройки rateSource, включающее мок-режим.
private const string MockRateSource = "mock";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
/// <summary>
/// Текущее состояние кэша курсов
/// </summary>
/// <returns>DTO: кэш из ratesCache или дефолт — мок-курсы, source <c>"mock"</c>, updatedAt null.</returns>
public async Task<RatesDto> GetAsync(CancellationToken ct)
{
RatesCacheValue? cache = (await TenantSettingsSnapshot.LoadAsync(store, ct)).TryGetRatesCache();
if (cache is not null)
{
return ToDto(cache);
}
return new RatesDto(BaseCurrency, MockRates.Values, MockSource, UpdatedAtMs: null);
}
/// <summary>
/// Обновляет кэш курсов по текущей настройке rateSource
/// </summary>
/// <returns>True — кэш обновлён (мок-режим всегда успешен); False — сбой источника, кэш не тронут.</returns>
public async Task<bool> RefreshAsync(CancellationToken ct)
{
string source = (await TenantSettingsSnapshot.LoadAsync(store, ct))
.GetString(SettingsKeys.RateSource, SettingsDefaults.RateSource);
if (source == MockRateSource)
{
await SaveCacheAsync(new RatesCacheValue(MockRates.Values, MockSource, UtcNowMs()), ct);
await NotifyRatesChangedAsync(ct);
return true;
}
Dictionary<string, double>? rates = await ratesSource.FetchAsync(ct);
if (rates is null)
{
return false;
}
await SaveCacheAsync(new RatesCacheValue(rates, CbrSource, UtcNowMs()), ct);
await NotifyRatesChangedAsync(ct);
return true;
}
private async Task NotifyRatesChangedAsync(CancellationToken ct)
{
foreach (IRatesChangedListener listener in listeners)
{
await listener.OnRatesChangedAsync(fullRecompute: true, ct);
}
}
/// <summary>
/// Нужно ли обновить кэш
/// </summary>
/// <returns>True — GET /api/rates должен запустить фоновое обновление.</returns>
public async Task<bool> ShouldFetchAsync(CancellationToken ct)
{
TenantSettingsSnapshot settings = await TenantSettingsSnapshot.LoadAsync(store, ct);
RatesCacheValue? cache = settings.TryGetRatesCache();
if (cache is null)
{
return true;
}
string source = NormalizeSource(settings.GetString(SettingsKeys.RateSource, SettingsDefaults.RateSource));
if (cache.Source != source)
{
return true;
}
long intervalMs = (long)MockRates.RatesFetchInterval.TotalMilliseconds;
return UtcNowMs() - cache.UpdatedAtMs >= intervalMs;
}
/// <summary>
/// Конвертирует сумму из одной валюты в другую по курсам к рублю.
/// </summary>
/// <param name="amount">Сумма в исходной валюте; null → null (поле бюджета не заполнено).</param>
/// <param name="fromCurrency">Код исходной валюты (RUB/USD/EUR/USDT/…).</param>
/// <param name="toCurrency">Код целевой валюты.</param>
/// <param name="rates">Курсы к рублю (кэш ratesCache или мок-курсы).</param>
/// <returns>Сумма в целевой валюте, округлённая до 2 знаков, или null при отсутствии валюты в курсах.</returns>
public static double? ConvertAmount(
double? amount,
string fromCurrency,
string toCurrency,
IReadOnlyDictionary<string, double> rates)
{
if (amount is null)
{
return null;
}
double? fromRate = ResolveRate(rates, fromCurrency);
double? toRate = ResolveRate(rates, toCurrency);
if (fromRate is null || toRate is null)
{
return null;
}
return Math.Round(amount.Value * fromRate.Value / toRate.Value, 2);
}
// Курс валюты: для USDT — курс USD, если он есть (иначе собственный курс USDT).
// rates: Курсы к рублю.
// currencyCode: Код валюты.
// Возвращает: Курс или null, если валюта отсутствует в курсах.
private static double? ResolveRate(IReadOnlyDictionary<string, double> rates, string currencyCode)
{
if (currencyCode == "USDT" && rates.TryGetValue("USD", out double usdRate))
{
return usdRate;
}
return rates.TryGetValue(currencyCode, out double rate) ? rate : null;
}
// Строит DTO ответа из значения кэша.
// cache: Значение кэша ratesCache.
// Возвращает: DTO {base, rates, source, updatedAt}.
private static RatesDto ToDto(RatesCacheValue cache)
{
return new RatesDto(BaseCurrency, cache.Rates, cache.Source, cache.UpdatedAtMs);
}
// Сохраняет кэш курсов в tenant-настройку ratesCache (upsert через порт).
// После неё RefreshAsync вызывает слушателей пересчёта (см. NotifyRatesChangedAsync).
// cache: Новое значение кэша.
// ct: Токен отмены.
private async Task SaveCacheAsync(RatesCacheValue cache, CancellationToken ct)
{
await store.SetAsync(SettingsKeys.RatesCache, JsonSerializer.Serialize(cache, JsonOptions), ct);
}
private static string NormalizeSource(string setting) => setting == MockRateSource ? MockSource : CbrSource;
private static long UtcNowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
}
@@ -0,0 +1,79 @@
using System.Security.Cryptography;
using System.Text.Json;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Modules.Settings.Application.Services;
// Часть SettingsService: PATCH-ключ myPrompts — чистка и сериализация «Моих промптов» (SerializeCleanMyPrompts
// и чтение полей элемента: ReadTrimmedField/ReadPromptId). colState — passthrough в главном ApplyPatchAsync.
public sealed partial class SettingsService
{
private static string SerializeCleanMyPrompts(JsonElement array)
{
var clean = new List<MyPromptDto>();
int considered = 0;
foreach (JsonElement item in array.EnumerateArray())
{
if (considered >= MaxMyPrompts)
{
break; // срез value[:100] — как в прототипе, до фильтрации
}
considered++;
if (item.ValueKind != JsonValueKind.Object)
{
continue;
}
string name = ReadTrimmedField(item, "name", PromptNameMaxLength);
string prompt = ReadTrimmedField(item, "prompt", PromptTextMaxLength);
if (name.Length == 0 || prompt.Length == 0)
{
continue; // пустые name/prompt — дроп
}
string description = ReadTrimmedField(item, "description", PromptDescriptionMaxLength);
string id = ReadPromptId(item);
clean.Add(new MyPromptDto(id, name, description, prompt));
}
return JsonSerializer.Serialize(clean, JsonOptions);
}
// Читает строковое поле промпта: trim + срез длины; отсутствие/не-скаляр → "".
// item: Элемент «Моих промптов» (JSON-объект).
// field: Имя поля (name/prompt/description).
// maxLength: Максимальная длина после trim.
// Возвращает: Значение поля (может быть пустым).
private static string ReadTrimmedField(
JsonElement item,
string field,
int maxLength)
{
if (!TryReadFieldText(item, field, out string text))
{
return string.Empty;
}
string trimmed = text.Trim();
return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength];
}
// Читает id промпта: как есть (срез 40) или генерирует pp_ + 8 hex.
// item: Элемент «Моих промптов» (JSON-объект).
// Возвращает: Идентификатор промпта.
private static string ReadPromptId(JsonElement item)
{
if (TryReadFieldText(item, "id", out string id))
{
string candidate = id.Length <= PromptIdMaxLength ? id : id[..PromptIdMaxLength];
if (candidate.Length > 0)
{
return candidate;
}
}
return PromptIdPrefix + Convert.ToHexString(RandomNumberGenerator.GetBytes(PromptIdRandomBytes)).ToLowerInvariant();
}
}
@@ -0,0 +1,140 @@
using System.Text.Json;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Modules.Settings.Application.Services;
// Часть SettingsService: применение скалярных PATCH-ключей — пары пауз авто-вступлений (PrepareDelayClamps),
// int/string-ключи и списки строк (ApplyIntKey/ApplyStringKey/SerializeStringList).
public sealed partial class SettingsService
{
private static Dictionary<string, int> PrepareDelayClamps(Dictionary<string, JsonElement> body, Dictionary<string, JsonElement> overrides)
{
var result = new Dictionary<string, int>(StringComparer.Ordinal);
(int min, int max) = IntClampRanges[SettingsKeys.DiscJoinDelayMin];
bool hasMin = body.TryGetValue(SettingsKeys.DiscJoinDelayMin, out JsonElement minElement);
bool hasMax = body.TryGetValue(SettingsKeys.DiscJoinDelayMax, out JsonElement maxElement);
if (hasMin && hasMax)
{
// Пришли оба конца: клампы (5..600) и min ≤ max; если нет — меняем местами, как делает фронт.
bool minOk = TryReadInt(minElement, out int pairMin);
bool maxOk = TryReadInt(maxElement, out int pairMax);
if (minOk && maxOk)
{
pairMin = Clamp(pairMin, min, max);
pairMax = Clamp(pairMax, min, max);
if (pairMin > pairMax)
{
(pairMin, pairMax) = (pairMax, pairMin);
}
result[SettingsKeys.DiscJoinDelayMin] = pairMin;
result[SettingsKeys.DiscJoinDelayMax] = pairMax;
}
// Оба конца пришли, но не «числовые» — общий цикл разберётся по ключам (нечисловое пропустит).
return result;
}
if (hasMin)
{
// Один конец: кламп относительно сохранённого другого конца (дефолт, если тот не переопределён).
if (TryReadInt(minElement, out int singleMin))
{
singleMin = Clamp(singleMin, min, max);
int currentMax = MergeInt(overrides, SettingsKeys.DiscJoinDelayMax, SettingsDefaults.DiscJoinDelayMax);
if (currentMax >= min)
{
singleMin = Math.Min(singleMin, currentMax);
}
result[SettingsKeys.DiscJoinDelayMin] = singleMin;
}
return result;
}
if (hasMax && TryReadInt(maxElement, out int singleMax))
{
singleMax = Clamp(singleMax, min, max);
int currentMin = MergeInt(overrides, SettingsKeys.DiscJoinDelayMin, SettingsDefaults.DiscJoinDelayMin);
if (currentMin <= max)
{
singleMax = Math.Max(singleMax, currentMin);
}
result[SettingsKeys.DiscJoinDelayMax] = singleMax;
}
return result;
}
// Применяет целочисленный ключ: кламп по каталогу, нечисловое — пропуск ключа.
// key: Имя ключа.
// value: JSON-значение из тела PATCH.
// writes: Накопитель записей (key → valueJson).
private static void ApplyIntKey(
string key,
JsonElement value,
Dictionary<string, string> writes)
{
if (!TryReadInt(value, out int number))
{
return; // нечисловое — пропуск ключа (L111–115)
}
if (IntClampRanges.TryGetValue(key, out (int Min, int Max) range))
{
number = Clamp(number, range.Min, range.Max);
}
writes[key] = JsonSerializer.Serialize(number);
}
// Применяет строковый ключ: targetCurrency — Upper; aiProvider — только из каталога провайдеров.
// key: Имя ключа.
// value: JSON-значение из тела PATCH.
// writes: Накопитель записей (key → valueJson).
private static void ApplyStringKey(
string key,
JsonElement value,
Dictionary<string, string> writes)
{
if (!TryReadText(value, out string? text) || text is null)
{
return; // не-скаляр — пропуск ключа
}
if (key == SettingsKeys.TargetCurrency)
{
text = text.ToUpperInvariant();
}
if (key == SettingsKeys.AiProvider && !ProviderIds.Contains(text))
{
return; // aiProvider вне списка провайдеров — пропуск (L134–135)
}
writes[key] = JsonSerializer.Serialize(text);
}
private static string SerializeStringList(JsonElement array)
{
var items = new List<string>();
foreach (JsonElement item in array.EnumerateArray())
{
if (TryReadText(item, out string? text) && text is not null)
{
items.Add(text);
}
}
if (items.Count > MaxListItems)
{
items = items.Take(MaxListItems).ToList();
}
return JsonSerializer.Serialize(items);
}
}
@@ -0,0 +1,102 @@
using System.Text.Json;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Modules.Settings.Application.Services;
// Часть SettingsService: PATCH-ключ секрета aiConfigs (ApplyAiConfigsKey, SSRF-гейт baseUrl, шифрование
// apiKey), включая удаление переопределения, вернувшегося к дефолту (EqualsDefault).
public sealed partial class SettingsService
{
private void ApplyAiConfigsKey(
JsonElement value,
Dictionary<string, JsonElement> overrides,
Dictionary<string, string> writes,
List<string> removals)
{
if (value.ValueKind != JsonValueKind.Object)
{
return;
}
// База — эффективные конфиги (дефолты + сохранённые переопределения): PATCH дописывает поля.
var effective = new Dictionary<string, AiConfigSetting>(
MergeAiConfigs(SettingsDefaults.AiConfigs, overrides),
StringComparer.Ordinal);
foreach (JsonProperty provider in value.EnumerateObject())
{
if (!effective.ContainsKey(provider.Name) || provider.Value.ValueKind != JsonValueKind.Object)
{
continue;
}
AiConfigSetting config = effective[provider.Name];
string providerId = provider.Name;
string apiKey = config.ApiKey;
string baseUrl = config.BaseUrl;
string model = config.Model;
JsonElement entry = provider.Value;
// Каталог провайдеров: для baseUrl-гейта нужен признак local и фиксированный адрес каталога.
AiProviderDefinition? meta = AiProviders.All.FirstOrDefault(p => p.Id == providerId);
// baseUrl: переопределение принимается ТОЛЬКО для локальных провайдеров (Ollama/LM Studio — свой
// адрес на машине пользователя) и custom (OpenAI-совместимый — адрес задаёт сам пользователь).
// У каталоговых облачных провайдеров (deepseek/openai/openrouter/anthropic) адрес фиксирован
// каталогом — иначе тенант мог бы перенаправить ключ/запрос на произвольный внутренний адрес
// (SSRF, Security review) и проверка подключения ушла бы на него.
bool baseUrlMutable = meta is { Local: true } || providerId == CustomProviderId;
if (baseUrlMutable && TryReadFieldText(entry, "baseUrl", out string newBaseUrl))
{
baseUrl = newBaseUrl;
}
if (TryReadFieldText(entry, "model", out string newModel))
{
model = newModel;
}
// apiKey: непустой, ≥8 симв., без префикса enc: и без маски (содержит "…") → шифруется;
// иначе (пустой/маска) ключ не меняется.
if (TryReadFieldText(entry, "apiKey", out string newKey)
&& newKey.Length >= ApiKeyMinLength
&& !newKey.StartsWith(EncryptedValuePrefix, StringComparison.Ordinal)
&& !newKey.Contains(MaskEllipsis, StringComparison.Ordinal))
{
apiKey = secretCipher.Encrypt(newKey);
}
effective[provider.Name] = new AiConfigSetting(apiKey, baseUrl, model);
}
if (EqualsDefault(effective))
{
removals.Add(SettingsKeys.AiConfigs);
}
else
{
writes[SettingsKeys.AiConfigs] = JsonSerializer.Serialize(effective, JsonOptions);
}
}
// True — эффективный aiConfigs совпадает с дефолтами (переопределение можно не хранить).
// configs: Эффективные конфиги.
// Возвращает: True — хранить переопределение не нужно.
private static bool EqualsDefault(IReadOnlyDictionary<string, AiConfigSetting> configs)
{
if (configs.Count != SettingsDefaults.AiConfigs.Count)
{
return false;
}
foreach ((string providerId, AiConfigSetting config) in configs)
{
if (!SettingsDefaults.AiConfigs.ContainsKey(providerId) || config != SettingsDefaults.AiConfigs[providerId])
{
return false;
}
}
return true;
}
}
@@ -0,0 +1,26 @@
using Deal.Modules.Settings.Application.Models;
namespace Deal.Modules.Settings.Application.Services;
public sealed partial class SettingsService
{
private AiConfigPublicDto ToPublic(AiConfigSetting config)
{
string plainKey = secretCipher.Decrypt(config.ApiKey);
return new AiConfigPublicDto(
BaseUrl: config.BaseUrl,
Model: config.Model,
KeySet: !string.IsNullOrEmpty(plainKey),
KeyMasked: MaskSecret(plainKey));
}
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)),
};
}
}
@@ -0,0 +1,251 @@
using System.Globalization;
using System.Text.Json;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Modules.Settings.Application.Services;
public sealed partial class SettingsService
{
private async Task<Dictionary<string, JsonElement>> LoadStoredOverridesAsync(CancellationToken ct)
{
var result = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
IReadOnlyCollection<SettingValue> rows = await store.GetAllAsync(ct);
foreach (SettingValue row in rows)
{
try
{
using JsonDocument document = JsonDocument.Parse(row.ValueJson);
result[row.Key] = document.RootElement.Clone();
}
catch (JsonException)
{
// Повреждённая строка не должна ронять снимок: значение трактуется как отсутствующее (дефолт).
}
}
return result;
}
private static bool TryReadInt(JsonElement element, out int value)
{
long wide;
if (element.ValueKind == JsonValueKind.Number)
{
if (!element.TryGetInt64(out wide))
{
value = 0;
return false;
}
}
else if (element.ValueKind == JsonValueKind.String)
{
if (!long.TryParse(element.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out wide))
{
value = 0;
return false;
}
}
else
{
value = 0;
return false;
}
// Диапазон long шире int: сжимаем (клампы PATCH всё равно ограничены диапазоном меньше int32).
value = (int)Math.Clamp(wide, int.MinValue, int.MaxValue);
return true;
}
private static bool TryReadBool(JsonElement element, out bool value)
{
if (element.ValueKind is JsonValueKind.True or JsonValueKind.False)
{
value = element.GetBoolean();
return true;
}
value = false;
return false;
}
private static bool TryReadText(JsonElement element, out string? text)
{
switch (element.ValueKind)
{
case JsonValueKind.String:
text = element.GetString();
return true;
case JsonValueKind.Number:
text = element.GetRawText();
return true;
case JsonValueKind.True:
text = "true";
return true;
case JsonValueKind.False:
text = "false";
return true;
default:
text = null;
return false;
}
}
// Читает строковое поле объекта (JSON null / отсутствие / не-скаляр → False).
// obj: JSON-объект.
// name: Имя поля.
// text: Значение поля.
// Возвращает: True — поле присутствует и скалярно.
private static bool TryReadFieldText(
JsonElement obj,
string name,
out string text)
{
if (obj.TryGetProperty(name, out JsonElement element) && TryReadText(element, out string? value) && value is not null)
{
text = value;
return true;
}
text = string.Empty;
return false;
}
// Кламп целого в диапазон.
// value: Исходное значение.
// min: Нижняя граница.
// max: Верхняя граница.
// Возвращает: Значение в диапазоне [min..max].
private static int Clamp(
int value,
int min,
int max) => Math.Max(min, Math.Min(max, value));
// Объединяет дефолт и переопределение целочисленного ключа.
private static int MergeInt(
Dictionary<string, JsonElement> overrides,
string key,
int defaultValue)
{
return overrides.TryGetValue(key, out JsonElement element) && TryReadInt(element, out int value)
? value
: defaultValue;
}
// Объединяет дефолт и переопределение булева ключа.
private static bool MergeBool(
Dictionary<string, JsonElement> overrides,
string key,
bool defaultValue)
{
return overrides.TryGetValue(key, out JsonElement element) && TryReadBool(element, out bool value)
? value
: defaultValue;
}
// Объединяет дефолт и переопределение строкового ключа.
private static string MergeString(
Dictionary<string, JsonElement> overrides,
string key,
string defaultValue)
{
return overrides.TryGetValue(key, out JsonElement element)
&& TryReadText(element, out string? value)
&& value is not null
? value
: defaultValue;
}
// Объединяет дефолт и переопределение списка строк.
private static IReadOnlyList<string> MergeStringList(
Dictionary<string, JsonElement> overrides,
string key,
IReadOnlyList<string> defaultValue)
{
if (overrides.TryGetValue(key, out JsonElement element) && element.ValueKind == JsonValueKind.Array)
{
var items = new List<string>();
foreach (JsonElement item in element.EnumerateArray())
{
if (TryReadText(item, out string? text) && text is not null)
{
items.Add(text);
}
}
return items;
}
return defaultValue;
}
// Объединяет дефолт и переопределение словаря (colState): значения сохраняются как JsonElement.
private static IReadOnlyDictionary<string, object?> MergeDict(Dictionary<string, JsonElement> overrides, string key)
{
if (overrides.TryGetValue(key, out JsonElement element) && element.ValueKind == JsonValueKind.Object)
{
var result = new Dictionary<string, object?>(StringComparer.Ordinal);
foreach (JsonProperty property in element.EnumerateObject())
{
result[property.Name] = property.Value.Clone();
}
return result;
}
return new Dictionary<string, object?>(StringComparer.Ordinal);
}
// Объединяет дефолты aiConfigs и сохранённое переопределение (переопределение — на провайдера).
// defaults: Дефолтные конфиги на всех провайдеров каталога.
// overrides: Сохранённые переопределения.
// Возвращает: Эффективные конфиги: провайдеры дефолтов + перекрытия из хранилища (неизвестные пропускаются).
private static IReadOnlyDictionary<string, AiConfigSetting> MergeAiConfigs(IReadOnlyDictionary<string, AiConfigSetting> defaults, Dictionary<string, JsonElement> overrides)
{
var merged = defaults.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
if (overrides.TryGetValue(SettingsKeys.AiConfigs, out JsonElement element) && element.ValueKind == JsonValueKind.Object)
{
foreach (JsonProperty provider in element.EnumerateObject())
{
// «Только существующие провайдеры»: неизвестные id из хранилища не подмешиваются.
if (!merged.ContainsKey(provider.Name) || provider.Value.ValueKind != JsonValueKind.Object)
{
continue;
}
string apiKey = TryReadFieldText(provider.Value, "apiKey", out string storedKey) ? storedKey : string.Empty;
string baseUrl = TryReadFieldText(provider.Value, "baseUrl", out string storedBase) ? storedBase : string.Empty;
string model = TryReadFieldText(provider.Value, "model", out string storedModel) ? storedModel : string.Empty;
merged[provider.Name] = new AiConfigSetting(apiKey, baseUrl, model);
}
}
return merged;
}
// Читает сохранённые «Мои промпты»; повреждённое значение → пустой список (дефолт).
// overrides: Сохранённые переопределения.
// Возвращает: Список промптов из хранилища или дефолт (пустой).
private static IReadOnlyList<MyPromptDto> MergeMyPrompts(Dictionary<string, JsonElement> overrides)
{
if (overrides.TryGetValue(SettingsKeys.MyPrompts, out JsonElement element) && element.ValueKind == JsonValueKind.Array)
{
try
{
return JsonSerializer.Deserialize<List<MyPromptDto>>(element.GetRawText(), JsonOptions)
?? new List<MyPromptDto>();
}
catch (JsonException)
{
return Array.Empty<MyPromptDto>();
}
}
return SettingsDefaults.MyPrompts;
}
}
@@ -0,0 +1,281 @@
using System.Text.Json;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Modules.Settings.Application.Services;
/// <summary>
/// Сервис настроек тенанта
/// </summary>
/// <param name="store">KV-хранилище настроек тенанта (таблица settings).</param>
/// <param name="secretCipher">Шифр секретов.</param>
/// <param name="listeners">Слушатели смены настроек конверсии; пусто — no-op.</param>
public sealed partial class SettingsService(
ISettingsStore store,
ISecretCipher secretCipher,
IEnumerable<IRatesChangedListener> listeners)
{
private const string EncryptedValuePrefix = "enc:";
// Символ-заполнитель маски секрета (U+2026, «1234…5678»): строка, содержащая его,
// — это маска из public-снимка, а не новый секрет (Security review: не шифровать маску).
private const string MaskEllipsis = "…";
// Id провайдера «Другой (OpenAI-совместимый)» — единственный не-local, чей baseUrl
// задаёт пользователь (остальным адрес фиксирован каталогом, SSRF-гейт).
private const string CustomProviderId = "custom";
// Максимум элементов списка-настройки при записи из PATCH (срез 200).
private const int MaxListItems = 200;
// Минимальная длина нового API-ключа, который принимает PATCH (≥8 симв.).
private const int ApiKeyMinLength = 8;
// Максимум элементов «Моих промптов» при записи из PATCH (≤100).
private const int MaxMyPrompts = 100;
// Максимальная длина названия промпта (≤80).
private const int PromptNameMaxLength = 80;
// Максимальная длина описания промпта (≤300).
private const int PromptDescriptionMaxLength = 300;
// Максимальная длина текста промпта (≤8000).
private const int PromptTextMaxLength = 8000;
// Максимальная длина id промпта из клиента (≤40); иначе генерируется.
private const int PromptIdMaxLength = 40;
// Префикс генерируемого id промпта (референс store.uid("pp_")).
private const string PromptIdPrefix = "pp_";
// Размер случайного суффикса id промпта в байтах (8 hex-символов).
private const int PromptIdRandomBytes = 4;
private static readonly IReadOnlyDictionary<string, (int Min, int Max)> IntClampRanges =
new Dictionary<string, (int Min, int Max)>
{
[SettingsKeys.ArchiveAfterDays] = (1, 30),
[SettingsKeys.MinLen] = (10, 500),
[SettingsKeys.DiscJoinLimit] = (1, 200),
[SettingsKeys.DiscJoinDelayMin] = (5, 600),
[SettingsKeys.DiscJoinDelayMax] = (5, 600),
[SettingsKeys.DiscEvalSample] = (3, 30),
[SettingsKeys.DiscEvalThreshold] = (1, 100),
};
// Идентификаторы известных провайдеров (валидация aiProvider/aiConfigs).
private static readonly IReadOnlySet<string> ProviderIds =
AiProviders.All.Select(provider => provider.Id).ToHashSet(StringComparer.Ordinal);
private static readonly IReadOnlyList<ProviderPublicDto> PublicProviders =
AiProviders.All
.Select(provider => new ProviderPublicDto(
provider.Id, provider.Name, provider.Base, provider.Local, provider.Models))
.ToList();
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
/// <summary>
/// Публичный снимок настроек
/// </summary>
/// <returns>Полный public-снимок (тело GET/PATCH /api/settings).</returns>
public async Task<PublicSettingsDto> GetPublicAsync(CancellationToken ct)
{
Dictionary<string, JsonElement> overrides = await LoadStoredOverridesAsync(ct);
return BuildSnapshot(overrides);
}
/// <summary>
/// Частичное обновление настроек
/// </summary>
/// <param name="body">Тело PATCH — произвольный JSON-объект из публичных ключей.</param>
/// <returns>Полный public-снимок после применения (как GET).</returns>
public async Task<PublicSettingsDto> ApplyPatchAsync(Dictionary<string, JsonElement> body, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(body);
Dictionary<string, JsonElement> overrides = await LoadStoredOverridesAsync(ct);
Dictionary<string, int> preparedDelays = PrepareDelayClamps(body, overrides);
var writes = new Dictionary<string, string>(StringComparer.Ordinal);
var removals = new List<string>();
foreach ((string key, JsonElement value) in body)
{
if (preparedDelays.TryGetValue(key, out int preparedValue))
{
writes[key] = JsonSerializer.Serialize(preparedValue);
continue;
}
SettingKind? kind = SettingsKeys.FindPublicKind(key);
if (kind is null)
{
continue; // неизвестный или внутренний ключ — игнорируем (Ruling 1)
}
switch (kind.Value)
{
case SettingKind.Int:
ApplyIntKey(key, value, writes);
break;
case SettingKind.Bool:
if (TryReadBool(value, out bool flag))
{
writes[key] = JsonSerializer.Serialize(flag);
}
break;
case SettingKind.String:
ApplyStringKey(key, value, writes);
break;
case SettingKind.List:
if (value.ValueKind == JsonValueKind.Array)
{
writes[key] = SerializeStringList(value);
}
break;
case SettingKind.Dict:
if (value.ValueKind == JsonValueKind.Object)
{
writes[key] = value.GetRawText();
}
break;
case SettingKind.MyPrompts:
if (value.ValueKind == JsonValueKind.Array)
{
writes[key] = SerializeCleanMyPrompts(value);
}
break;
case SettingKind.AiConfigs:
ApplyAiConfigsKey(value, overrides, writes, removals);
break;
}
}
foreach ((string key, string valueJson) in writes)
{
await store.SetAsync(key, valueJson, ct);
}
foreach (string key in removals)
{
await store.RemoveAsync(key, ct);
}
if (AffectsConversion(body))
{
await NotifyRatesChangedAsync(ct);
}
return await GetPublicAsync(ct);
}
private static bool AffectsConversion(Dictionary<string, JsonElement> body)
{
return HasNonNull(body, SettingsKeys.TargetCurrency) || HasNonNull(body, SettingsKeys.ConversionOn);
}
// Есть ли в теле ключ с не-null JSON-значением.
// body: Тело PATCH.
// key: Имя ключа.
// Возвращает: True — ключ присутствует и значение не JSON-null.
private static bool HasNonNull(Dictionary<string, JsonElement> body, string key)
{
return body.TryGetValue(key, out JsonElement value) && value.ValueKind != JsonValueKind.Null;
}
private async Task NotifyRatesChangedAsync(CancellationToken ct)
{
foreach (IRatesChangedListener listener in listeners)
{
await listener.OnRatesChangedAsync(fullRecompute: true, ct);
}
}
// Собирает public-снимок из дефолтов и переопределений (дефолты хранилище не пишет).
// overrides: Сохранённые переопределения: ключ → распарсенный JSON.
// Возвращает: DTO public-снимка.
private PublicSettingsDto BuildSnapshot(Dictionary<string, JsonElement> overrides)
{
IReadOnlyDictionary<string, AiConfigSetting> aiConfigs = MergeAiConfigs(SettingsDefaults.AiConfigs, overrides);
var result = new PublicSettingsDto
{
// Хранилище / обработка
AutoArchive = MergeBool(overrides, SettingsKeys.AutoArchive, SettingsDefaults.AutoArchive),
ArchiveAfterDays = MergeInt(overrides, SettingsKeys.ArchiveAfterDays, SettingsDefaults.ArchiveAfterDays),
ArchiveClearDays = MergeInt(overrides, SettingsKeys.ArchiveClearDays, SettingsDefaults.ArchiveClearDays),
TrashClearDays = MergeInt(overrides, SettingsKeys.TrashClearDays, SettingsDefaults.TrashClearDays),
MinLen = MergeInt(overrides, SettingsKeys.MinLen, SettingsDefaults.MinLen),
StopPhrases = MergeStringList(overrides, SettingsKeys.StopPhrases, SettingsDefaults.StopPhrases),
// ИИ
MlEnabled = MergeBool(overrides, SettingsKeys.MlEnabled, SettingsDefaults.MlEnabled),
AiEnabled = MergeBool(overrides, SettingsKeys.AiEnabled, SettingsDefaults.AiEnabled),
AiFilterEnabled = MergeBool(overrides, SettingsKeys.AiFilterEnabled, SettingsDefaults.AiFilterEnabled),
AiPrompt = MergeString(overrides, SettingsKeys.AiPrompt, SettingsDefaults.AiPrompt),
AiFilterPrompt = MergeString(overrides, SettingsKeys.AiFilterPrompt, SettingsDefaults.AiFilterPrompt),
CardPrompt = MergeString(overrides, SettingsKeys.CardPrompt, SettingsDefaults.CardPrompt),
// Сфера и ключи
WantedType = MergeString(overrides, SettingsKeys.WantedType, SettingsDefaults.WantedType),
BudgetRequiredHire = MergeBool(overrides, SettingsKeys.BudgetRequiredHire, SettingsDefaults.BudgetRequiredHire),
BudgetRequiredOrder = MergeBool(overrides, SettingsKeys.BudgetRequiredOrder, SettingsDefaults.BudgetRequiredOrder),
HireLabel = MergeString(overrides, SettingsKeys.HireLabel, SettingsDefaults.HireLabel),
OrderLabel = MergeString(overrides, SettingsKeys.OrderLabel, SettingsDefaults.OrderLabel),
DomainDescription = MergeString(overrides, SettingsKeys.DomainDescription, SettingsDefaults.DomainDescription),
DomainKeywords = MergeStringList(overrides, SettingsKeys.DomainKeywords, SettingsDefaults.DomainKeywords),
HireMarkers = MergeStringList(overrides, SettingsKeys.HireMarkers, SettingsDefaults.HireMarkers),
LevelTerms = MergeStringList(overrides, SettingsKeys.LevelTerms, SettingsDefaults.LevelTerms),
ResumeMarkers = MergeStringList(overrides, SettingsKeys.ResumeMarkers, SettingsDefaults.ResumeMarkers),
BlockResumes = MergeBool(overrides, SettingsKeys.BlockResumes, SettingsDefaults.BlockResumes),
ExcludeKeywords = MergeStringList(overrides, SettingsKeys.ExcludeKeywords, SettingsDefaults.ExcludeKeywords),
ExcludeLocations = MergeStringList(overrides, SettingsKeys.ExcludeLocations, SettingsDefaults.ExcludeLocations),
ExcludeTypes = MergeStringList(overrides, SettingsKeys.ExcludeTypes, SettingsDefaults.ExcludeTypes),
ExcludeBudgetFrom = MergeInt(overrides, SettingsKeys.ExcludeBudgetFrom, SettingsDefaults.ExcludeBudgetFrom),
ExcludeBudgetTo = MergeInt(overrides, SettingsKeys.ExcludeBudgetTo, SettingsDefaults.ExcludeBudgetTo),
MyPrompts = MergeMyPrompts(overrides),
// Напоминания / валюта
RemindersEnabled = MergeBool(overrides, SettingsKeys.RemindersEnabled, SettingsDefaults.RemindersEnabled),
ConversionOn = MergeBool(overrides, SettingsKeys.ConversionOn, SettingsDefaults.ConversionOn),
TargetCurrency = MergeString(overrides, SettingsKeys.TargetCurrency, SettingsDefaults.TargetCurrency),
RateSource = MergeString(overrides, SettingsKeys.RateSource, SettingsDefaults.RateSource),
// Telegram / Discovery
AutoMonitorNew = MergeBool(overrides, SettingsKeys.AutoMonitorNew, SettingsDefaults.AutoMonitorNew),
DiscJoinLimit = MergeInt(overrides, SettingsKeys.DiscJoinLimit, SettingsDefaults.DiscJoinLimit),
DiscJoinDelayMin = MergeInt(overrides, SettingsKeys.DiscJoinDelayMin, SettingsDefaults.DiscJoinDelayMin),
DiscJoinDelayMax = MergeInt(overrides, SettingsKeys.DiscJoinDelayMax, SettingsDefaults.DiscJoinDelayMax),
DiscEvalSample = MergeInt(overrides, SettingsKeys.DiscEvalSample, SettingsDefaults.DiscEvalSample),
DiscEvalThreshold = MergeInt(overrides, SettingsKeys.DiscEvalThreshold, SettingsDefaults.DiscEvalThreshold),
DiscPaused = MergeBool(overrides, SettingsKeys.DiscPaused, SettingsDefaults.DiscPaused),
ColState = MergeDict(overrides, SettingsKeys.ColState),
// ИИ-провайдеры (публичная форма)
AiProvider = MergeString(overrides, SettingsKeys.AiProvider, SettingsDefaults.AiProvider),
AiConfigs = aiConfigs.ToDictionary(pair => pair.Key, pair => ToPublic(pair.Value), StringComparer.Ordinal),
Providers = PublicProviders,
};
return result;
}
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Deal.SharedKernel\Deal.SharedKernel.csproj" />
<ProjectReference Include="..\Deal.Contracts\Deal.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.11" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
@@ -0,0 +1,8 @@
namespace Deal.Modules.Settings;
/// <summary>
/// Маркер модуля Settings
/// </summary>
public interface ISettingsModule
{
}