feat(api,core,domain): HelpDocumentations - методы по получению и кэшированию документации из
This commit is contained in:
@@ -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
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
20
PARR.API/Contracts/V1/Responses/HelpDocumentResponse.cs
Normal file
20
PARR.API/Contracts/V1/Responses/HelpDocumentResponse.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
172
PARR.API/Controllers/V1/HelpDocumentController.cs
Normal file
172
PARR.API/Controllers/V1/HelpDocumentController.cs
Normal 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, "Внутренняя ошибка обработки вебхука");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
23
PARR.Domain/DTOs/HelpDocument/HelpDocumentResult.cs
Normal file
23
PARR.Domain/DTOs/HelpDocument/HelpDocumentResult.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
28
PARR.Domain/Settings/HelpDocumentSettings.cs
Normal file
28
PARR.Domain/Settings/HelpDocumentSettings.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user