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,141 @@
|
||||
using Deal.Api.Endpoints.RequestModels;
|
||||
using Deal.Api.Extensions;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Api.Telegram;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
namespace Deal.Api.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Операторские ручки глобальных
|
||||
/// </summary>
|
||||
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";
|
||||
|
||||
/// <summary>
|
||||
/// Регистрирует группу /api/operator/settings
|
||||
/// </summary>
|
||||
/// <param name="app">Построитель маршрутов приложения.</param>
|
||||
/// <returns>Построитель маршрутов для цепочки вызовов.</returns>
|
||||
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<IResult> 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<IResult> 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);
|
||||
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user