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:
@@ -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 — короткий заголовок (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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+140
@@ -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
|
||||
{
|
||||
}
|
||||
Reference in New Issue
Block a user