Dev #11

Merged
serega404 merged 87 commits from dev into main 2026-05-25 03:22:55 +03:00
5 changed files with 122 additions and 6 deletions
Showing only changes of commit 9b28a09253 - Show all commits
@@ -7,7 +7,13 @@ public record SyncScheduleRequest(
IReadOnlyList<string>? TypeId IReadOnlyList<string>? TypeId
); );
public record SyncResultDto(int Created, int Updated, int Skipped, string? Error); public record SyncResultDto(
int Created,
int Updated,
int Skipped,
string? Error,
IReadOnlyList<string>? Details = null
);
public record SyncStatusDto(DateTime? LastSyncAt, string Status, SyncResultDto? LastResult); public record SyncStatusDto(DateTime? LastSyncAt, string Status, SyncResultDto? LastResult);
@@ -23,7 +23,8 @@ public class ModeusApiClient : IModeusApiClient
{ {
var body = new { specialtyCode = request.SpecialtyCode, timeMin = request.TimeMin, timeMax = request.TimeMax, typeId = request.TypeId }; var body = new { specialtyCode = request.SpecialtyCode, timeMin = request.TimeMin, timeMax = request.TimeMax, typeId = request.TypeId };
var response = await _http.PostAsJsonAsync("/api/proxy/events/search", body); var response = await _http.PostAsJsonAsync("/api/proxy/events/search", body);
response.EnsureSuccessStatusCode(); await EnsureSuccessAsync(response, "Modeus events search",
$"specialtyCode={request.SpecialtyCode ?? "<empty>"}, timeMin={request.TimeMin:O}, timeMax={request.TimeMax:O}, typeId=[{string.Join(", ", request.TypeId ?? [])}]");
return await response.Content.ReadFromJsonAsync<ModeusEventsResponse>() ?? new(new()); return await response.Content.ReadFromJsonAsync<ModeusEventsResponse>() ?? new(new());
} }
@@ -46,7 +47,8 @@ public class ModeusApiClient : IModeusApiClient
}; };
var response = await _http.PostAsJsonAsync("/api/proxy/rooms/search", body); var response = await _http.PostAsJsonAsync("/api/proxy/rooms/search", body);
response.EnsureSuccessStatusCode(); await EnsureSuccessAsync(response, "Modeus rooms search",
$"name=<empty>, sort=+building.name,+name, size={pageSize}, page={page}, deleted=false");
var payload = await response.Content.ReadFromJsonAsync<ModeusRoomsResponse>() ?? new ModeusRoomsResponse(); var payload = await response.Content.ReadFromJsonAsync<ModeusRoomsResponse>() ?? new ModeusRoomsResponse();
allRooms.AddRange(payload.RoomItems); allRooms.AddRange(payload.RoomItems);
@@ -59,6 +61,20 @@ public class ModeusApiClient : IModeusApiClient
return new ModeusRoomsResponse { Rooms = allRooms }; return new ModeusRoomsResponse { Rooms = allRooms };
} }
private static async Task EnsureSuccessAsync(HttpResponseMessage response, string operation, string requestSummary)
{
if (response.IsSuccessStatusCode) return;
var responseBody = await response.Content.ReadAsStringAsync();
if (responseBody.Length > 2000)
responseBody = string.Concat(responseBody.AsSpan(0, 2000), "...<truncated>");
throw new HttpRequestException(
$"{operation} failed with HTTP {(int)response.StatusCode} {response.ReasonPhrase}. Request: {requestSummary}. Response body: {responseBody}",
null,
response.StatusCode);
}
public async Task<List<ModeusEmployee>> SearchEmployeeAsync(string fullname) public async Task<List<ModeusEmployee>> SearchEmployeeAsync(string fullname)
{ {
var response = await _http.GetFromJsonAsync<List<ModeusEmployee>>( var response = await _http.GetFromJsonAsync<List<ModeusEmployee>>(
@@ -21,6 +21,7 @@ public class ScheduleSyncService : IScheduleSyncService
public async Task<SyncResultDto> SyncScheduleAsync(SyncScheduleRequest request) public async Task<SyncResultDto> SyncScheduleAsync(SyncScheduleRequest request)
{ {
const string stage = "schedule";
int created = 0, updated = 0, skipped = 0; int created = 0, updated = 0, skipped = 0;
try try
{ {
@@ -45,7 +46,18 @@ public class ScheduleSyncService : IScheduleSyncService
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Schedule sync failed"); _logger.LogError(ex, "Schedule sync failed");
var result = new SyncResultDto(created, updated, skipped, ex.Message); var result = new SyncResultDto(created, updated, skipped, ex.Message, BuildErrorDetails(
ex,
stage,
created,
updated,
skipped,
[
$"specialtyCode={request.SpecialtyCode ?? "<empty>"}",
$"timeMin={request.TimeMin:O}",
$"timeMax={request.TimeMax:O}",
$"typeId=[{string.Join(", ", request.TypeId ?? [])}]"
]));
_lastStatus = new SyncStatusDto(DateTime.UtcNow, "failed", result); _lastStatus = new SyncStatusDto(DateTime.UtcNow, "failed", result);
return result; return result;
} }
@@ -53,6 +65,7 @@ public class ScheduleSyncService : IScheduleSyncService
public async Task<SyncResultDto> SyncRoomsAsync() public async Task<SyncResultDto> SyncRoomsAsync()
{ {
const string stage = "rooms";
int created = 0, updated = 0, skipped = 0; int created = 0, updated = 0, skipped = 0;
try try
{ {
@@ -96,7 +109,13 @@ public class ScheduleSyncService : IScheduleSyncService
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Rooms sync failed"); _logger.LogError(ex, "Rooms sync failed");
var result = new SyncResultDto(created, updated, skipped, ex.Message); var result = new SyncResultDto(created, updated, skipped, ex.Message, BuildErrorDetails(
ex,
stage,
created,
updated,
skipped,
["request=name:<empty>, sort:+building.name,+name, deleted:false, page size:100"]));
_lastStatus = new SyncStatusDto(DateTime.UtcNow, "failed", result); _lastStatus = new SyncStatusDto(DateTime.UtcNow, "failed", result);
return result; return result;
} }
@@ -109,4 +128,30 @@ public class ScheduleSyncService : IScheduleSyncService
} }
public Task<SyncStatusDto> GetLastSyncStatusAsync() => Task.FromResult(_lastStatus); public Task<SyncStatusDto> GetLastSyncStatusAsync() => Task.FromResult(_lastStatus);
private static IReadOnlyList<string> BuildErrorDetails(
Exception exception,
string stage,
int created,
int updated,
int skipped,
IReadOnlyList<string> context)
{
var details = new List<string>
{
$"stage={stage}",
$"exceptionType={exception.GetType().FullName}",
$"message={exception.Message}",
$"partialResult=created:{created}, updated:{updated}, skipped:{skipped}"
};
if (exception is HttpRequestException httpException && httpException.StatusCode.HasValue)
details.Add($"httpStatus={(int)httpException.StatusCode.Value} {httpException.StatusCode.Value}");
if (exception.InnerException != null)
details.Add($"innerException={exception.InnerException.GetType().FullName}: {exception.InnerException.Message}");
details.AddRange(context);
return details;
}
} }
+1
View File
@@ -166,6 +166,7 @@ export interface SyncResultDto {
updated: number updated: number
skipped: number skipped: number
error?: string | null error?: string | null
details?: string[] | null
} }
export interface UserAchievementDto { export interface UserAchievementDto {
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, inject, onMounted, ref } from 'vue' import { computed, inject, onMounted, ref } from 'vue'
import { coursesApi, lecturesApi, locationsApi, syncApi, tagsApi } from '@/api' import { coursesApi, lecturesApi, locationsApi, syncApi, tagsApi } from '@/api'
import { ApiError } from '@/api/client'
import type { import type {
ApiScheduleTypeId, ApiScheduleTypeId,
CourseDto, CourseDto,
@@ -30,6 +31,7 @@ const loading = ref(false)
const syncingSchedule = ref(false) const syncingSchedule = ref(false)
const syncingRooms = ref(false) const syncingRooms = ref(false)
const syncError = ref('') const syncError = ref('')
const syncErrorDetails = ref<string[]>([])
const syncStatus = ref<SyncStatusDto | null>(null) const syncStatus = ref<SyncStatusDto | null>(null)
const syncResult = ref<SyncResultDto | null>(null) const syncResult = ref<SyncResultDto | null>(null)
const addToast = inject('addToast') as ((message: string, type?: 'success' | 'error' | 'info') => void) | undefined const addToast = inject('addToast') as ((message: string, type?: 'success' | 'error' | 'info') => void) | undefined
@@ -66,6 +68,10 @@ const syncMeta = computed(() => {
}) })
const visibleSyncResult = computed(() => syncResult.value ?? syncStatus.value?.lastResult ?? null) const visibleSyncResult = computed(() => syncResult.value ?? syncStatus.value?.lastResult ?? null)
const visibleSyncDetails = computed(() => {
if (syncErrorDetails.value.length) return syncErrorDetails.value
return visibleSyncResult.value?.details ?? []
})
const tabConfig: Record<TabKey, TabConfig> = { const tabConfig: Record<TabKey, TabConfig> = {
lectures: { lectures: {
@@ -182,6 +188,7 @@ async function refreshSyncStatus() {
async function runScheduleSync() { async function runScheduleSync() {
syncingSchedule.value = true syncingSchedule.value = true
syncError.value = '' syncError.value = ''
syncErrorDetails.value = []
syncResult.value = null syncResult.value = null
try { try {
syncResult.value = await syncApi.schedule({ syncResult.value = await syncApi.schedule({
@@ -193,6 +200,7 @@ async function runScheduleSync() {
if (syncResult.value.error) { if (syncResult.value.error) {
syncError.value = syncResult.value.error syncError.value = syncResult.value.error
syncErrorDetails.value = syncResult.value.details ?? []
addToast?.('Синхронизация завершилась с ошибкой.', 'error') addToast?.('Синхронизация завершилась с ошибкой.', 'error')
} else { } else {
addToast?.('Расписание синхронизировано.', 'success') addToast?.('Расписание синхронизировано.', 'success')
@@ -201,6 +209,7 @@ async function runScheduleSync() {
await loadData() await loadData()
} catch (err) { } catch (err) {
syncError.value = err instanceof Error ? err.message : 'Не удалось синхронизировать расписание.' syncError.value = err instanceof Error ? err.message : 'Не удалось синхронизировать расписание.'
syncErrorDetails.value = extractApiErrorDetails(err)
addToast?.(syncError.value, 'error') addToast?.(syncError.value, 'error')
await refreshSyncStatus() await refreshSyncStatus()
} finally { } finally {
@@ -211,19 +220,41 @@ async function runScheduleSync() {
async function runRoomsSync() { async function runRoomsSync() {
syncingRooms.value = true syncingRooms.value = true
syncError.value = '' syncError.value = ''
syncErrorDetails.value = []
syncResult.value = null syncResult.value = null
try { try {
syncResult.value = await syncApi.rooms() syncResult.value = await syncApi.rooms()
if (syncResult.value.error) {
syncError.value = syncResult.value.error
syncErrorDetails.value = syncResult.value.details ?? []
addToast?.('Синхронизация аудиторий завершилась с ошибкой.', 'error')
} else {
addToast?.('Аудитории синхронизированы.', 'success') addToast?.('Аудитории синхронизированы.', 'success')
}
await loadData() await loadData()
} catch (err) { } catch (err) {
syncError.value = err instanceof Error ? err.message : 'Не удалось синхронизировать аудитории.' syncError.value = err instanceof Error ? err.message : 'Не удалось синхронизировать аудитории.'
syncErrorDetails.value = extractApiErrorDetails(err)
addToast?.(syncError.value, 'error') addToast?.(syncError.value, 'error')
} finally { } finally {
syncingRooms.value = false syncingRooms.value = false
} }
} }
function extractApiErrorDetails(err: unknown) {
if (!(err instanceof ApiError)) return []
const details = err.details
if (!details || typeof details !== 'object') return []
const problem = details as { title?: unknown; detail?: unknown; traceId?: unknown; status?: unknown }
return [
typeof problem.title === 'string' ? `title=${problem.title}` : '',
typeof problem.status === 'number' ? `status=${problem.status}` : '',
typeof problem.detail === 'string' ? `detail=${problem.detail}` : '',
typeof problem.traceId === 'string' ? `traceId=${problem.traceId}` : '',
].filter(Boolean)
}
onMounted(() => { onMounted(() => {
void loadData() void loadData()
}) })
@@ -282,6 +313,12 @@ onMounted(() => {
<div v-if="syncError || visibleSyncResult?.error" class="sync-error"> <div v-if="syncError || visibleSyncResult?.error" class="sync-error">
{{ syncError || visibleSyncResult?.error }} {{ syncError || visibleSyncResult?.error }}
</div> </div>
<details v-if="visibleSyncDetails.length" class="sync-details">
<summary>Подробности ошибки</summary>
<ul>
<li v-for="detail in visibleSyncDetails" :key="detail">{{ detail }}</li>
</ul>
</details>
<div class="form-actions"> <div class="form-actions">
<button class="btn-primary" type="submit" :disabled="syncingSchedule"> <button class="btn-primary" type="submit" :disabled="syncingSchedule">
@@ -326,6 +363,17 @@ onMounted(() => {
.sync-meta { font-size: 12px; color: var(--color-text-secondary); margin-top: 4px; } .sync-meta { font-size: 12px; color: var(--color-text-secondary); margin-top: 4px; }
.sync-result { font-size: 13px; color: var(--color-text-secondary); } .sync-result { font-size: 13px; color: var(--color-text-secondary); }
.sync-error { font-size: 13px; color: var(--color-error); } .sync-error { font-size: 13px; color: var(--color-error); }
.sync-details {
border: 1px solid rgba(239,68,68,0.24);
border-radius: var(--radius-sm);
padding: 8px 10px;
background: rgba(254,242,242,0.68);
color: var(--color-text-secondary);
font-size: 12px;
}
.sync-details summary { cursor: pointer; color: var(--color-error); font-weight: 600; }
.sync-details ul { margin: 8px 0 0; padding-left: 18px; overflow-wrap: anywhere; }
.sync-details li + li { margin-top: 4px; }
.sync-status { .sync-status {
flex: 0 0 auto; flex: 0 0 auto;
border: 1px solid var(--color-border-glass); border: 1px solid var(--color-border-glass);