feat: улучшил синхронизацию лекций
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Failing after 10m14s
Frontend CI / build-and-check (push) Failing after 16m12s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Failing after 14m7s
🚀 Create and publish a Docker image / Build & publish backend image (push) Failing after 14m59s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Failing after 14m57s
Backend CI / build-and-test (push) Failing after 13m27s

This commit is contained in:
2026-05-24 23:47:23 +03:00
parent a8a20f9b0b
commit 85ef2a1c22
7 changed files with 492 additions and 132 deletions
@@ -19,10 +19,11 @@ public class SyncController : ControllerBase
/// <summary>Запустить синхронизацию расписания лекций из Modeus.</summary>
/// <remarks>
/// Только Admin. Выполняет upsert лекций и связанных курсов на основе данных
/// из внешнего API `schedule.rdcenter.ru`. Поддерживает фильтрацию по специальности,
/// периоду и типу занятий.
/// из внешнего API `schedule.rdcenter.ru`. Поддерживает фильтрацию по периоду,
/// размеру выборки, аудиториям, участникам, реализациям курсов/циклов,
/// специальностям, годам набора, профилям, учебным планам и типам занятий.
/// </remarks>
/// <param name="req">Параметры синхронизации: specialtyCode, timeMin/timeMax, typeId.</param>
/// <param name="req">Параметры поиска событий во внешнем сервисе расписания.</param>
/// <response code="200">Результат синхронизации: кол-во созданных, обновлённых и пропущенных записей.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
+61 -3
View File
@@ -2994,9 +2994,9 @@
"Sync"
],
"summary": "Запустить синхронизацию расписания лекций из Modeus.",
"description": "Только Admin. Выполняет upsert лекций и связанных курсов на основе данных\nиз внешнего API `schedule.rdcenter.ru`. Поддерживает фильтрацию по специальности,\nпериоду и типу занятий.\n\n**Required roles:** Admin",
"description": "Только Admin. Выполняет upsert лекций и связанных курсов на основе данных\nиз внешнего API `schedule.rdcenter.ru`. Поддерживает фильтрацию по периоду,\nразмеру выборки, аудиториям, участникам, реализациям курсов/циклов,\nспециальностям, годам набора, профилям, учебным планам и типам занятий.\n\n**Required roles:** Admin",
"requestBody": {
"description": "Параметры синхронизации: specialtyCode, timeMin/timeMax, typeId.",
"description": "Параметры поиска событий во внешнем сервисе расписания.",
"content": {
"application/json": {
"schema": {
@@ -5725,7 +5725,10 @@
"type": "object",
"properties": {
"specialtyCode": {
"type": "string",
"type": "array",
"items": {
"type": "string"
},
"nullable": true
},
"timeMin": {
@@ -5744,6 +5747,61 @@
"type": "string"
},
"nullable": true
},
"size": {
"type": "integer",
"format": "int32",
"nullable": true
},
"roomId": {
"type": "array",
"items": {
"type": "string"
},
"nullable": true
},
"attendeePersonId": {
"type": "array",
"items": {
"type": "string"
},
"nullable": true
},
"courseUnitRealizationId": {
"type": "array",
"items": {
"type": "string"
},
"nullable": true
},
"cycleRealizationId": {
"type": "array",
"items": {
"type": "string"
},
"nullable": true
},
"learningStartYear": {
"type": "array",
"items": {
"type": "integer",
"format": "int32"
},
"nullable": true
},
"profileName": {
"type": "array",
"items": {
"type": "string"
},
"nullable": true
},
"curriculumId": {
"type": "array",
"items": {
"type": "string"
},
"nullable": true
}
},
"additionalProperties": false
@@ -1,10 +1,18 @@
namespace UniVerse.Application.DTOs.Sync;
public record SyncScheduleRequest(
string? SpecialtyCode,
IReadOnlyList<string>? SpecialtyCode,
DateTime? TimeMin,
DateTime? TimeMax,
IReadOnlyList<string>? TypeId
IReadOnlyList<string>? TypeId,
int? Size = null,
IReadOnlyList<string>? RoomId = null,
IReadOnlyList<string>? AttendeePersonId = null,
IReadOnlyList<string>? CourseUnitRealizationId = null,
IReadOnlyList<string>? CycleRealizationId = null,
IReadOnlyList<int>? LearningStartYear = null,
IReadOnlyList<string>? ProfileName = null,
IReadOnlyList<string>? CurriculumId = null
);
public record SyncResultDto(
@@ -24,29 +24,30 @@ public class ModeusApiClient : IModeusApiClient
public async Task<ModeusEventsResponse> SearchEventsAsync(SyncScheduleRequest request)
{
const int pageSize = 900;
var specialtyCodes = string.IsNullOrWhiteSpace(request.SpecialtyCode)
? []
: request.SpecialtyCode
.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
var typeIds = request.TypeId ?? [];
var pageSize = request.Size is > 0 ? request.Size.Value : 900;
var body = new Dictionary<string, object?>
{
["size"] = pageSize,
["timeMin"] = request.TimeMin,
["timeMax"] = request.TimeMax,
["specialtyCode"] = specialtyCodes
["timeMax"] = request.TimeMax
};
if (typeIds.Count > 0)
body["typeId"] = typeIds;
AddNonEmpty(body, "roomId", request.RoomId);
AddNonEmpty(body, "attendeePersonId", request.AttendeePersonId);
AddNonEmpty(body, "courseUnitRealizationId", request.CourseUnitRealizationId);
AddNonEmpty(body, "cycleRealizationId", request.CycleRealizationId);
AddNonEmpty(body, "specialtyCode", request.SpecialtyCode);
AddNonEmpty(body, "learningStartYear", request.LearningStartYear);
AddNonEmpty(body, "profileName", request.ProfileName);
AddNonEmpty(body, "curriculumId", request.CurriculumId);
AddNonEmpty(body, "typeId", request.TypeId);
var response = await _http.PostAsJsonAsync("/api/proxy/events/search", body);
var requestJson = JsonSerializer.Serialize(body);
await EnsureSuccessAsync(response, "Modeus events search",
BuildEventsRequestSummary(pageSize, specialtyCodes, request.TimeMin, request.TimeMax, typeIds, requestJson));
BuildEventsRequestSummary(requestJson));
return await ReadJsonAsync<ModeusEventsResponse>(response, "Modeus events search",
BuildEventsRequestSummary(pageSize, specialtyCodes, request.TimeMin, request.TimeMax, typeIds, requestJson))
BuildEventsRequestSummary(requestJson))
?? new ModeusEventsResponse();
}
@@ -97,16 +98,15 @@ public class ModeusApiClient : IModeusApiClient
response.StatusCode);
}
private static string BuildEventsRequestSummary(
int size,
IReadOnlyList<string> specialtyCodes,
DateTime? timeMin,
DateTime? timeMax,
IReadOnlyList<string> typeIds,
string requestJson)
private static string BuildEventsRequestSummary(string requestJson) => $"Request JSON: {requestJson}";
private static void AddNonEmpty<T>(
IDictionary<string, object?> body,
string key,
IReadOnlyList<T>? values)
{
var typeFilter = typeIds.Count > 0 ? $"typeId=[{string.Join(", ", typeIds)}]" : "typeId=<omitted>";
return $"size={size}, specialtyCode=[{string.Join(", ", specialtyCodes)}], timeMin={timeMin:O}, timeMax={timeMax:O}, {typeFilter}. Request JSON: {requestJson}";
if (values is { Count: > 0 })
body[key] = values;
}
private static async Task<T?> ReadJsonAsync<T>(HttpResponseMessage response, string operation, string requestSummary)
@@ -104,9 +104,6 @@ public class ScheduleSyncService : IScheduleSyncService
catch (Exception ex)
{
_logger.LogError(ex, "Schedule sync failed");
var specialtyCodes = string.IsNullOrWhiteSpace(request.SpecialtyCode)
? []
: request.SpecialtyCode.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
var result = new SyncResultDto(created, updated, skipped, ex.Message, BuildErrorDetails(
ex,
stage,
@@ -114,14 +111,9 @@ public class ScheduleSyncService : IScheduleSyncService
updated,
skipped,
[
"size=900",
$"requestJson={BuildScheduleRequestJson(request)}",
$"specialtyCode=[{string.Join(", ", specialtyCodes)}]",
$"timeMin={request.TimeMin:O}",
$"timeMax={request.TimeMax:O}",
request.TypeId is { Count: > 0 }
? $"typeId=[{string.Join(", ", request.TypeId)}]"
: "typeId=<omitted>"
$"timeMax={request.TimeMax:O}"
]));
_lastStatus = new SyncStatusDto(DateTime.UtcNow, "failed", result);
return result;
@@ -498,24 +490,35 @@ public class ScheduleSyncService : IScheduleSyncService
private static string BuildScheduleRequestJson(SyncScheduleRequest request)
{
var specialtyCodes = string.IsNullOrWhiteSpace(request.SpecialtyCode)
? []
: request.SpecialtyCode.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
var body = new Dictionary<string, object?>
{
["size"] = 900,
["size"] = request.Size is > 0 ? request.Size.Value : 900,
["timeMin"] = request.TimeMin,
["timeMax"] = request.TimeMax,
["specialtyCode"] = specialtyCodes
["timeMax"] = request.TimeMax
};
if (request.TypeId is { Count: > 0 })
body["typeId"] = request.TypeId;
AddNonEmpty(body, "roomId", request.RoomId);
AddNonEmpty(body, "attendeePersonId", request.AttendeePersonId);
AddNonEmpty(body, "courseUnitRealizationId", request.CourseUnitRealizationId);
AddNonEmpty(body, "cycleRealizationId", request.CycleRealizationId);
AddNonEmpty(body, "specialtyCode", request.SpecialtyCode);
AddNonEmpty(body, "learningStartYear", request.LearningStartYear);
AddNonEmpty(body, "profileName", request.ProfileName);
AddNonEmpty(body, "curriculumId", request.CurriculumId);
AddNonEmpty(body, "typeId", request.TypeId);
return JsonSerializer.Serialize(body);
}
private static void AddNonEmpty<T>(
IDictionary<string, object?> body,
string key,
IReadOnlyList<T>? values)
{
if (values is { Count: > 0 })
body[key] = values;
}
private static string? GetHrefId(string? href)
{
if (string.IsNullOrWhiteSpace(href))