using Deal.Api.Endpoints.RequestModels; using Deal.Api.Http; using Deal.Api.Telegram; using Deal.Modules.Tenants.Application; using Deal.Modules.Tenants.Application.Models; namespace Deal.Api.Endpoints; /// /// Операторские ручки глобальных (системных) настроек: ключи приложения Telegram /// (ТЗ §4.1/§8.1). /// /// /// Все ручки — только под операторской сессией: без неё 401 «Требуется вход оператора». Ключи Telegram /// задаёт оператор глобально (едины для всех тенантов), тенант их не видит и не задаёт. /// /// GET /api/operator/settings/telegram-keys — маскированный снимок: apiId (не секрет, открыт), /// apiHash (маска) и keysSet; /// PUT /api/operator/settings/telegram-keys {apiId?, apiHash?} — частичное сохранение (можно /// передать только одно поле, второе сохраняется); валидация (api_id 5..9 цифр, api_hash непустой), /// шифрование секрета и аудит telegram_keys_changed (без секретов в деталях). /// /// Ошибки — 400/401 {detail} (формат прототипа, Ruling 10). /// public static class OperatorSettingsEndpoints { // Префикс группы операторских настроек. private const string SettingsGroupPrefix = "/api/operator/settings"; // OpenAPI-тег группы. private const string SettingsOpenApiTag = "operator-settings"; // Относительный путь глобальных ключей Telegram (GET/PUT). private const string TelegramKeysPath = "/telegram-keys"; // Текст 400: пустое тело PUT (ни одного поля). private const string EmptyBodyDetail = "Укажите api_id и api_hash"; // Текст 400: частичное обновление, но ключей ещё нет — нужны оба поля. private const string MissingKeysDetail = "Ключи ещё не заданы — укажите и api_id, и api_hash"; // Текст 400: api_id не 5..9 цифр. private const string InvalidApiIdDetail = "api_id должен состоять из 5–9 цифр"; // Текст 400: api_hash пустой/маска/с префиксом enc:. private const string InvalidApiHashDetail = "Укажите непустой api_hash"; /// /// Регистрирует группу /api/operator/settings: telegram-keys (GET/PUT). /// /// Построитель маршрутов приложения. /// Построитель маршрутов для цепочки вызовов. public static IEndpointRouteBuilder MapOperatorSettingsEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup(SettingsGroupPrefix).WithTags(SettingsOpenApiTag); group.MapGet(TelegramKeysPath, GetTelegramKeysAsync); group.MapPut(TelegramKeysPath, PutTelegramKeysAsync); return app; } // GET /api/operator/settings/telegram-keys: маскированные глобальные ключи Telegram. // context: Контекст запроса. // keys: Сервис глобальных ключей Telegram (scoped). // ct: Токен отмены. // Возвращает: 200 маскированный снимок или 401 без операторской сессии. private static async Task GetTelegramKeysAsync( HttpContext context, TelegramKeysService keys, CancellationToken ct) { if (context.GetCurrentOperator() is null) { return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); } TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct); return Results.Ok(snapshot); } // PUT /api/operator/settings/telegram-keys: частичное сохранение глобальных ключей Telegram // оператором. // Поля можно передавать по отдельности: непереданное поле (null) сохраняет текущее значение, // явное значение (в т.ч. пустая строка) валидируется. Если ключей ещё нет, оба поля обязательны. // body: Тело {apiId?, apiHash?} (хотя бы одно поле). // context: Контекст запроса. // keys: Сервис глобальных ключей Telegram (scoped). // auditService: Сервис аудита (событие telegram_keys_changed). // ct: Токен отмены. // Возвращает: 200 маскированный снимок, 400 при невалидных/недостающих полях или 401 без операторской сессии. private static async Task PutTelegramKeysAsync( OperatorTelegramKeysRequest? body, HttpContext context, TelegramKeysService keys, AuditService auditService, CancellationToken ct) { var operatorIdentity = context.GetCurrentOperator(); if (operatorIdentity is null) { return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); } if (body is null) { return EndpointResults.BadRequest(EmptyBodyDetail); } // null — поле не передано (сохраняем текущее); непустая строка/плейсхолдер — валидируем явно. string? apiId = body.ApiId?.Trim(); string? apiHash = body.ApiHash?.Trim(); if (apiId is null && apiHash is null) { return EndpointResults.BadRequest(EmptyBodyDetail); } if (apiId is not null && !TelegramKeysService.IsValidApiId(apiId)) { return EndpointResults.BadRequest(InvalidApiIdDetail); } if (apiHash is not null && !TelegramKeysService.IsValidApiHash(apiHash)) { return EndpointResults.BadRequest(InvalidApiHashDetail); } // Частичное обновление: недостающее поле берём из текущих ключей; если ключей ещё нет — нужны оба. TgKeysSnapshot current = await keys.GetAsync(ct); string effectiveApiId = apiId ?? current.ApiId; string effectiveApiHash = apiHash ?? current.ApiHash; if (effectiveApiId.Length == 0 || effectiveApiHash.Length == 0) { return EndpointResults.BadRequest(MissingKeysDetail); } await keys.SaveAsync(effectiveApiId, effectiveApiHash, ct); // Аудит смены глобальных ключей: apiId — не секрет, apiHash в детали не пишется (Ruling 4). await auditService.AppendAsync(new AuditRecordDto( AuditEvents.TelegramKeysChanged, AuditActorTypes.Operator, ActorId: operatorIdentity.OperatorId, TenantId: null, Ip: ClientIp(context), DetailJson: AuditService.ToDetailJson(new { apiId = effectiveApiId, apiHashSet = true })), ct); TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct); return Results.Ok(snapshot); } // IP-адрес клиента для аудита (без порта; null, если недоступен). // context: Контекст запроса. // Возвращает: Строковое представление IP или null. private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); }