feat(api,core,domain): HelpDocumentations - методы по получению и кэшированию документации из

This commit is contained in:
Mikhail Trubnikov
2026-10-12 14:14:30 +10:00
parent 9f5bae3f32
commit 04d9843cfb
15 changed files with 590 additions and 1 deletions

View File

@@ -615,6 +615,17 @@ namespace PARR.API.Contracts.V1
#endregion
#region HelpDocument
public static class HelpDocument
{
public const string GetDocument = Base + "/help-documents/get-document";
public const string GitLabWebhook = Base + "/help-documents/modified";
}
#endregion
}
}

View File

@@ -0,0 +1,20 @@
namespace PARR.API.Contracts.V1.Responses
{
public record HelpDocumentResponse
{
/// <summary>
/// Уникальный ID (например: "ui/billing/invoice-create/vat-calc")
/// </summary>
public string HelpId { get; init; } = string.Empty;
/// <summary>
/// Заголовок из метаданных файла (title)
/// </summary>
public string Title { get; init; } = string.Empty;
/// <summary>
/// // Чистый Markdown-текст (все, что идет после метаданных)
/// </summary>
public string Content { get; init; } = string.Empty;
}
}

View File

@@ -0,0 +1,172 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PARR.API.Contracts.V1;
using PARR.API.Contracts.V1.Responses;
using PARR.API.Contracts.V1.Responses.Base;
using PARR.API.Controllers.V1.Base;
using PARR.Core.Services.HelpDocument.Interfaces;
using PARR.Domain.Common.Roles;
using PARR.Domain.Settings;
using System.Text.Json.Nodes;
namespace PARR.API.Controllers.V1
{
/// <summary>
/// Контроллер по работе со справочной документацией
/// </summary>
public class HelpDocumentController : BaseApiController
{
private readonly IHelpDocumentService _helpDocumentService;
private readonly IMapper _mapper;
private readonly HelpDocumentSettings _helpDocumentSettings;
private readonly ILogger<HelpDocumentController> _logger;
public HelpDocumentController(
IHelpDocumentService helpDocumentService,
IMapper mapper,
HelpDocumentSettings helpDocumentSettings,
ILogger<HelpDocumentController> logger
)
{
_helpDocumentService = helpDocumentService;
_mapper = mapper;
_helpDocumentSettings = helpDocumentSettings;
_logger = logger;
}
/// <summary>
/// Получить документ справки
/// </summary>
/// <returns></returns>
/// <param name="helpId">Путь к справке (например: ui/billing/invoice-create/vat-calc)</param>
[Authorize(Roles = ParrRoles.Administrator.Role)]
[HttpGet(ApiRoutes.HelpDocument.GetDocument)]
public async Task<IActionResult> GetHelpDocument([FromQuery] string helpId)
{
if (string.IsNullOrWhiteSpace(helpId))
throw new ArgumentNullException(nameof(helpId), "Значение не может быть пустым");
var documnet = await _helpDocumentService.GetHelpDocumentAsync(helpId);
if (documnet == null)
return NotFound();
var response = _mapper.Map<HelpDocumentResponse>(documnet);
return Ok(new Response<HelpDocumentResponse>(response, true));
}
/// <summary>
/// Вебхук от GitLab для автоматического сброса кэша при пуше изменений
/// </summary>
/// <returns></returns>
[AllowAnonymous]
[HttpPost(ApiRoutes.HelpDocument.GitLabWebhook)]
public async Task<IActionResult> GitLabWebhook([FromBody] JsonNode payload)
{
_logger.LogInformation("Получен Webhook запрос от GitLab.");
#region Проверка безопасности
// GitLab передает токен в заголовке X-Gitlab-Token
if (!Request.Headers.TryGetValue("X-Gitlab-Token", out var receivedToken))
{
_logger.LogWarning("Запрос вебхука отклонен. Отсутствует заголовок X-Gitlab-Token.");
return Unauthorized("Отсутствует авторизационный токен вебхука.");
}
if (receivedToken.ToString() != _helpDocumentSettings.WebhookToken)
{
_logger.LogWarning("Запрос вебхука отклонен. Передан невалидный Secret Token.");
return Unauthorized("Неверный токен вебхука.");
}
#endregion
_logger.LogDebug("Проверка Secret Token пройдена успешно. Начинается обработка изменений.");
try
{
// Проверяем, что это событие push
var objectKind = payload["object_kind"]?.ToString();
if (objectKind != "push")
{
_logger.LogDebug("Игнорируем вебхук: тип события '{ObjectKind}' не является 'push'.", objectKind);
return Ok(new { message = "Ignored non-push event" });
}
// Извлекаем массив коммитов
var commits = payload["commits"]?.AsArray();
if (commits == null || commits.Count == 0)
{
_logger.LogDebug("В вебхуке отсутствуют коммиты.");
return Ok(new { message = "No commits found" });
}
var filesToInvalidate = new HashSet<string>();
// Пробегаемся по коммитам и собираем измененные/добавленные файлы
foreach (var commit in commits)
{
var modifiedFiles = commit?["modified"]?.AsArray();
if (modifiedFiles != null)
{
foreach (var file in modifiedFiles)
{
var filePath = file?.ToString();
if (filePath != null && filePath.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
{
filesToInvalidate.Add(filePath);
}
}
}
var addedFiles = commit?["added"]?.AsArray();
if (addedFiles != null)
{
foreach (var file in addedFiles)
{
var filePath = file?.ToString();
if (filePath != null && filePath.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
{
filesToInvalidate.Add(filePath);
}
}
}
}
if (filesToInvalidate.Count == 0)
{
_logger.LogDebug("Среди измененных файлов в коммитах нет документов .md. Кэш не изменен.");
return Ok(new { message = "No .md files modified" });
}
_logger.LogInformation("Вебхук зафиксировал изменения в {Count} файлах справок. Начинается сброс кэша...", filesToInvalidate.Count);
// Инвалидируем кэш для каждого измененного файла
foreach (var file in filesToInvalidate)
{
// Превращаем путь "ui/billing/vat-calc.md" обратно в helpId "ui/billing/vat-calc"
var helpId = file.Substring(0, file.Length - 4);
_logger.LogDebug("Сброс кэша по вебхуку для HelpId: {HelpId}", helpId);
await _helpDocumentService.ClearCacheAsync(helpId);
}
_logger.LogInformation("Сброс кэша по вебхуку GitLab успешно завершен.");
return Ok(new { clearedFilesCount = filesToInvalidate.Count });
}
catch (Exception ex)
{
_logger.LogError(ex, "Ошибка при обработке Webhook от GitLab");
// Возвращаем 200 или 500 в зависимости от политики.
// Обычно возвращают BadRequest/InternalServerError, чтобы в GitLab в истории вебхуков была видна ошибка отладки.
return StatusCode(500, "Внутренняя ошибка обработки вебхука");
}
}
}
}

View File

@@ -1,4 +1,5 @@
using PARR.API.Settings;
using PARR.Domain.Settings;
namespace PARR.API.Installers
{
@@ -27,6 +28,10 @@ namespace PARR.API.Installers
configuration.GetSection(nameof(CommonSettings)).Bind(commonSettings);
services.AddSingleton(commonSettings);
var helpDocumentSettings = new HelpDocumentSettings();
configuration.GetSection(nameof(HelpDocumentSettings)).Bind(helpDocumentSettings);
services.AddSingleton(helpDocumentSettings);
//TODO: add other
}
}

View File

@@ -5,6 +5,7 @@ using PARR.API.Contracts.V1.Responses.Statistics;
using PARR.API.Controllers.V1.Statistics;
using PARR.API.MappingProfiles.Resolvers;
using PARR.Core.Repositories.Interfaces.Schedule;
using PARR.Domain.DTOs.HelpDocument;
using PARR.Domain.DTOs.Matching;
using PARR.Domain.DTOs.RobotMetrics;
using PARR.Domain.DTOs.RobotSnapshotDTO;
@@ -390,7 +391,7 @@ namespace PARR.API.MappingProfiles
.ForMember(d => d.GroupingUnitField, o => o.MapFrom(s => s.GroupingUnitField))
.ForMember(d => d.IsWorkGroupTimezone, o => o.MapFrom(s => s.IsResponseAreaTimezone))
.ForMember(d => d.AutoControl, o => o.MapFrom(s => s.AutoControl))
.ForMember(d => d.UnitFilters, o => o.MapFrom(s => s.UnitFilters.OrderBy(t=>t.UnitFilter)));
.ForMember(d => d.UnitFilters, o => o.MapFrom(s => s.UnitFilters.OrderBy(t => t.UnitFilter)));
CreateMap<JobGroup, JobGroupBaseResponse>()
.ForMember(d => d.Name, o => o.MapFrom(s => s.GroupName))
@@ -565,6 +566,12 @@ namespace PARR.API.MappingProfiles
#endregion
#region HelpDocumentResponse
CreateMap<HelpDocumentResult, HelpDocumentResponse>();
#endregion
}
}

View File

@@ -118,5 +118,15 @@
},
"CommonSettings": {
"ScheduleCooldownDuration": "03:00:00"
},
"HelpDocumentSettings": {
"GitLabSettings": {
"BaseUrl": "http://gitlab.dvgd.oao.rzd/api/v4/",
"ProjectId": "1343",
"Token": "glpat-2PBp1c9hryQHUJnoZn3S",
"DefaultBranch": "main"
},
"CacheTTL": "23:59:00",
"WebhookToken": "ParrSecret2026"
}
}

View File

@@ -5,6 +5,8 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
using PARR.Core.Common.Helpers;
using PARR.Core.Common.Implementations;
using PARR.Core.Common.Interfaces;
using PARR.Core.Services.HelpDocument.Implementations;
using PARR.Core.Services.HelpDocument.Interfaces;
using PARR.Core.Services.MatchingStatusService;
using PARR.Core.Services.NextRunServices;
using PARR.Core.Services.NextRunServices.Subservices;
@@ -174,6 +176,16 @@ namespace PARR.Core
#endregion
#region
// Добавляем дефолтные настройки, при необходимости переопределить в воркерах (API)
services.TryAddSingleton<HelpDocumentSettings>();
services.AddHttpClient<IHelpDocumentProvider, GitLabHelpProvider>();
services.AddScoped<IHelpDocumentService, HelpDocumentService>();
#endregion
return services;
}

View File

@@ -13,6 +13,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.16" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.16" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.16" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.16" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,166 @@
using Microsoft.Extensions.Logging;
using PARR.Core.Services.HelpDocument.Interfaces;
using PARR.Domain.DTOs.HelpDocument;
using PARR.Domain.Settings;
using System.Web;
namespace PARR.Core.Services.HelpDocument.Implementations
{
/// <summary>
/// Получение справочной документации из GitLab
/// </summary>
internal class GitLabHelpProvider : IHelpDocumentProvider
{
private readonly HttpClient _httpClient;
private readonly HelpDocumentSettings _helpDocumentSettings;
private readonly ILogger<GitLabHelpProvider> _logger;
public GitLabHelpProvider(HttpClient httpClient, HelpDocumentSettings helpDocumentSettings, ILogger<GitLabHelpProvider> logger)
{
_httpClient = httpClient;
_helpDocumentSettings = helpDocumentSettings;
_logger = logger;
// Базовая настройка http
_httpClient = httpClient;
if (_helpDocumentSettings.GitLabSettings == null)
{
_logger.LogCritical("Инициализация провайдера справки провалена: не задана секция GitLabSettings");
throw new ArgumentNullException(nameof(_helpDocumentSettings.GitLabSettings), "Не заданы настройки подключения к GitLab");
}
_httpClient.BaseAddress = new Uri(_helpDocumentSettings.GitLabSettings.BaseUrl);
_httpClient.DefaultRequestHeaders.Add("PRIVATE-TOKEN", _helpDocumentSettings.GitLabSettings.Token);
}
public async Task<HelpDocumentResult?> GetDocumentAsync(string helpId)
{
//1. Формируем путь к файлу
var filePath = $"{helpId}.md";
// 2. GitLab API требует, чтобы относительный путь к файлу в URL был закодирован (экранирован)
var urlEncodedPath = HttpUtility.UrlEncode(filePath);
// 3. Собираем эндпоинт для получения RAW контента файла
string relativeRequestUrl = $"projects/{_helpDocumentSettings.GitLabSettings!.ProjectId}/repository/files/{urlEncodedPath}/raw?ref={_helpDocumentSettings.GitLabSettings.DefaultBranch}";
try
{
// Создаем URI строго с флагом UriKind.Relative. Это заставит HttpClient отправить %2F в сыром виде!
var relativeUri = new Uri(relativeRequestUrl, UriKind.Relative);
_logger.LogDebug("Отправка HTTP GET запроса к GitLab API. Относительный URI: {RelativeUri}", relativeRequestUrl);
var response = await _httpClient.GetAsync(relativeUri);
// Если файла нет (404) или токен не подошел (401/403)
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Документ справки не найден или недоступен в GitLab. HelpId: {HelpId}, StatusCode: {StatusCode}",
helpId, response.StatusCode);
return null;
}
_logger.LogDebug("Файл справки успешно получен из GitLab. Размер ответа: {ContentLength} байт",
response.Content.Headers.ContentLength);
// Получаем весь текст файла (и шапку, и контент)
string rawFileContent = await response.Content.ReadAsStringAsync();
// 4. Парсим файл
var result = ParseMarkdownFile(helpId, rawFileContent);
_logger.LogDebug("Документ справки успешно загружен и распарсен. HelpId: {HelpId}, Title: '{Title}'",
helpId, result.Title);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Ошибка при запросе справки {HelpId} из GitLab", helpId);
return null;
}
}
private HelpDocumentResult ParseMarkdownFile(string helpId, string rawContent)
{
_logger.LogDebug("Начало парсинга структуры Markdown для HelpId: {HelpId}", helpId);
if (string.IsNullOrWhiteSpace(rawContent))
{
_logger.LogWarning("Получен пустой контент для HelpId: {HelpId}", helpId);
return new HelpDocumentResult { HelpId = helpId };
}
// Стандартное разделение строк с поддержкой разных типов окончаний (Windows/Linux)
var lines = rawContent.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None);
var title = string.Empty;
var contentStartIndex = 0;
var isInsideFrontMatter = false;
var frontMatterClosed = false;
for (int i = 0; i < lines.Length; i++)
{
var line = lines[i].Trim();
// Ищем маркеры начала и конца метаданных "---"
if (line == "---")
{
if (!isInsideFrontMatter && !frontMatterClosed)
{
isInsideFrontMatter = true;
_logger.LogDebug("Обнаружено начало блока метаданных (Front Matter) на строке {LineIndex}", i);
continue;
}
else if (isInsideFrontMatter)
{
isInsideFrontMatter = false;
frontMatterClosed = true;
contentStartIndex = i + 1; // Контент начинается строго со следующей строки
_logger.LogDebug("Обнаружен конец блока метаданных на строке {LineIndex}", i);
break;
}
}
// Читаем свойства внутри блока метаданных
if (isInsideFrontMatter)
{
if (line.StartsWith("title:", StringComparison.OrdinalIgnoreCase))
{
title = line.Substring("title:".Length).Trim();
_logger.LogDebug("В метаданных найден Title: '{Title}'", title);
}
// Тут в будущем можно парсить другие поля, например: icon, min_role и т.д.
}
}
string cleanContent;
if (frontMatterClosed)
{
// Собираем обратно чистый контент, исключая строки с шапкой
cleanContent = string.Join(Environment.NewLine, lines.Skip(contentStartIndex));
}
else
{
// Если шапки "---" вообще не было в файле, берем весь текст как есть
_logger.LogDebug("Блок метаданных '---' не найден в файле. Весь текст перенесен в Content.");
cleanContent = rawContent;
title = Path.GetFileNameWithoutExtension(helpId); // Дефолтный заголовок из имени файла
}
return new HelpDocumentResult
{
HelpId = helpId,
Title = title,
Content = cleanContent
};
}
}
}

View File

@@ -0,0 +1,88 @@
using Microsoft.Extensions.Logging;
using PARR.Core.Common.Interfaces;
using PARR.Core.Services.HelpDocument.Interfaces;
using PARR.Domain.Cache;
using PARR.Domain.DTOs.HelpDocument;
using PARR.Domain.Settings;
namespace PARR.Core.Services.HelpDocument.Implementations
{
internal class HelpDocumentService : IHelpDocumentService
{
private readonly IHelpDocumentProvider _helpProvider;
private readonly IRedisCacheService _redisCacheService;
private readonly HelpDocumentSettings _helpDocumentSettings;
private readonly ILogger<HelpDocumentService> _logger;
public HelpDocumentService(
IHelpDocumentProvider helpProvider,
IRedisCacheService redisCacheService,
HelpDocumentSettings helpDocumentSettings,
ILogger<HelpDocumentService> logger
)
{
_helpProvider = helpProvider;
_redisCacheService = redisCacheService;
_helpDocumentSettings = helpDocumentSettings;
_logger = logger;
}
public async Task<HelpDocumentResult?> GetHelpDocumentAsync(string helpId)
{
if (string.IsNullOrEmpty(helpId))
{
_logger.LogWarning("Значение helpId: '{helpId}' пустое.", helpId);
return null;
}
var cacheKey = _redisCacheService.GetKey(CacheKeys.HelpDocument.HelpDocumentItem(helpId));
_logger.LogDebug("Запрос справки. Проверка наличия в Redis по ключу: {CacheKey}", cacheKey);
// 1. Попытка получить данные из Redis
var cachedData = await _redisCacheService.GetCachedDataAsync<HelpDocumentResult?>(cacheKey, useCompression: true);
if (cachedData != null)
{
_logger.LogInformation("Документ справки успешно извлечен из кэша Redis. HelpId: {HelpId}", helpId);
return cachedData;
}
_logger.LogDebug("Кэш пуст или недоступен. Запрос документа напрямую через провайдер для HelpId: {HelpId}", helpId);
// 2. Если в Redis пусто, идем в GitLab через наш провайдер
var remoteDocument = await _helpProvider.GetDocumentAsync(helpId);
if (remoteDocument == null)
{
_logger.LogWarning("Документ не найден ни в Redis, ни в GitLab. HelpId: {HelpId}", helpId);
return null;
}
// 3. Сохраняем полученный документ в Redis для последующих запросов
await _redisCacheService.SetCachedDataAsync(cacheKey, remoteDocument, _helpDocumentSettings.CacheTTL, useCompression: true);
_logger.LogDebug("Вернул документацию из провайдера для HelpId: {HelpId}", helpId);
return remoteDocument;
}
public async Task ClearCacheAsync(string helpId)
{
if (string.IsNullOrEmpty(helpId))
{
_logger.LogWarning("Попытка очистки кэша: передан пустой helpId.");
return;
}
var cacheKey = _redisCacheService.GetKey(CacheKeys.HelpDocument.HelpDocumentItem(helpId));
_logger.LogInformation("Удаление документа справки из кэша Redis по вебхуку. Ключ: {CacheKey}", cacheKey);
await _redisCacheService.DeleteCachedDataAsync(cacheKey);
}
}
}

View File

@@ -0,0 +1,14 @@
using PARR.Domain.DTOs.HelpDocument;
namespace PARR.Core.Services.HelpDocument.Interfaces
{
internal interface IHelpDocumentProvider
{
/// <summary>
/// Запрашивает сырой файл .md из источника (например gitlab)
/// </summary>
/// <param name="helpId"></param>
/// <returns></returns>
Task<HelpDocumentResult?> GetDocumentAsync(string helpId);
}
}

View File

@@ -0,0 +1,24 @@
using PARR.Domain.DTOs.HelpDocument;
namespace PARR.Core.Services.HelpDocument.Interfaces
{
/// <summary>
/// Сервис загрузки справочной документации
/// </summary>
public interface IHelpDocumentService
{
/// <summary>
/// Получить документ справки по его уникальному идентификатору (с учетом кэширования в Redis)
/// </summary>
/// <param name="helpId">Уникальный путь справке</param>
/// <returns></returns>
Task<HelpDocumentResult?> GetHelpDocumentAsync(string helpId);
/// <summary>
/// Сбрасывает кэш в Redis для конкретного документа справки (вызывается вебхуком)
/// </summary>
/// <param name="helpId"></param>
/// <returns></returns>
Task ClearCacheAsync(string helpId);
}
}

View File

@@ -102,6 +102,14 @@
public static string[] UnitItem(Guid unitId) => [unitId.ToString()];
}
/// <summary>
/// Справочная документация
/// </summary>
public static class HelpDocument
{
public static string[] HelpDocumentItem(string helpId) => ["help-document", helpId.ToLowerInvariant()];
}
#region Helpers

View File

@@ -0,0 +1,23 @@
namespace PARR.Domain.DTOs.HelpDocument
{
/// <summary>
/// Модель справки, берется из .md
/// </summary>
public record HelpDocumentResult
{
/// <summary>
/// Уникальный ID (например: "ui/billing/invoice-create/vat-calc")
/// </summary>
public string HelpId { get; init; } = string.Empty;
/// <summary>
/// Заголовок из метаданных файла (title)
/// </summary>
public string Title { get; init; } = string.Empty;
/// <summary>
/// // Чистый Markdown-текст (все, что идет после метаданных)
/// </summary>
public string Content { get; init; } = string.Empty;
}
}

View File

@@ -0,0 +1,28 @@
namespace PARR.Domain.Settings
{
/// <summary>
/// Настройки получения справочной документации
/// </summary>
public record HelpDocumentSettings
{
public GitLabSettings? GitLabSettings { get; init; }
/// <summary>
/// Время хранения кэша, по умолчанию 2 часа.
/// </summary>
public TimeSpan CacheTTL { get; init; } = TimeSpan.FromHours(1);
public string WebhookToken { get; set; } = string.Empty;
}
/// <summary>
/// Настройки подключения к GitLab
/// </summary>
public record GitLabSettings
{
public string BaseUrl { get; init; } = string.Empty;
public string ProjectId { get; init; } = string.Empty;
public string Token { get; init; } = string.Empty;
public string DefaultBranch { get; init; } = string.Empty;
}
}