Dev #11
@@ -7,7 +7,13 @@ public record SyncScheduleRequest(
|
||||
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);
|
||||
|
||||
|
||||
@@ -23,7 +23,8 @@ public class ModeusApiClient : IModeusApiClient
|
||||
{
|
||||
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);
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -46,7 +47,8 @@ public class ModeusApiClient : IModeusApiClient
|
||||
};
|
||||
|
||||
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();
|
||||
allRooms.AddRange(payload.RoomItems);
|
||||
@@ -59,6 +61,20 @@ public class ModeusApiClient : IModeusApiClient
|
||||
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)
|
||||
{
|
||||
var response = await _http.GetFromJsonAsync<List<ModeusEmployee>>(
|
||||
|
||||
@@ -21,6 +21,7 @@ public class ScheduleSyncService : IScheduleSyncService
|
||||
|
||||
public async Task<SyncResultDto> SyncScheduleAsync(SyncScheduleRequest request)
|
||||
{
|
||||
const string stage = "schedule";
|
||||
int created = 0, updated = 0, skipped = 0;
|
||||
try
|
||||
{
|
||||
@@ -45,7 +46,18 @@ public class ScheduleSyncService : IScheduleSyncService
|
||||
catch (Exception ex)
|
||||
{
|
||||
_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);
|
||||
return result;
|
||||
}
|
||||
@@ -53,6 +65,7 @@ public class ScheduleSyncService : IScheduleSyncService
|
||||
|
||||
public async Task<SyncResultDto> SyncRoomsAsync()
|
||||
{
|
||||
const string stage = "rooms";
|
||||
int created = 0, updated = 0, skipped = 0;
|
||||
try
|
||||
{
|
||||
@@ -96,7 +109,13 @@ public class ScheduleSyncService : IScheduleSyncService
|
||||
catch (Exception ex)
|
||||
{
|
||||
_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);
|
||||
return result;
|
||||
}
|
||||
@@ -109,4 +128,30 @@ public class ScheduleSyncService : IScheduleSyncService
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +166,7 @@ export interface SyncResultDto {
|
||||
updated: number
|
||||
skipped: number
|
||||
error?: string | null
|
||||
details?: string[] | null
|
||||
}
|
||||
|
||||
export interface UserAchievementDto {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
import { coursesApi, lecturesApi, locationsApi, syncApi, tagsApi } from '@/api'
|
||||
import { ApiError } from '@/api/client'
|
||||
import type {
|
||||
ApiScheduleTypeId,
|
||||
CourseDto,
|
||||
@@ -30,6 +31,7 @@ const loading = ref(false)
|
||||
const syncingSchedule = ref(false)
|
||||
const syncingRooms = ref(false)
|
||||
const syncError = ref('')
|
||||
const syncErrorDetails = ref<string[]>([])
|
||||
const syncStatus = ref<SyncStatusDto | null>(null)
|
||||
const syncResult = ref<SyncResultDto | null>(null)
|
||||
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 visibleSyncDetails = computed(() => {
|
||||
if (syncErrorDetails.value.length) return syncErrorDetails.value
|
||||
return visibleSyncResult.value?.details ?? []
|
||||
})
|
||||
|
||||
const tabConfig: Record<TabKey, TabConfig> = {
|
||||
lectures: {
|
||||
@@ -182,6 +188,7 @@ async function refreshSyncStatus() {
|
||||
async function runScheduleSync() {
|
||||
syncingSchedule.value = true
|
||||
syncError.value = ''
|
||||
syncErrorDetails.value = []
|
||||
syncResult.value = null
|
||||
try {
|
||||
syncResult.value = await syncApi.schedule({
|
||||
@@ -193,6 +200,7 @@ async function runScheduleSync() {
|
||||
|
||||
if (syncResult.value.error) {
|
||||
syncError.value = syncResult.value.error
|
||||
syncErrorDetails.value = syncResult.value.details ?? []
|
||||
addToast?.('Синхронизация завершилась с ошибкой.', 'error')
|
||||
} else {
|
||||
addToast?.('Расписание синхронизировано.', 'success')
|
||||
@@ -201,6 +209,7 @@ async function runScheduleSync() {
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
syncError.value = err instanceof Error ? err.message : 'Не удалось синхронизировать расписание.'
|
||||
syncErrorDetails.value = extractApiErrorDetails(err)
|
||||
addToast?.(syncError.value, 'error')
|
||||
await refreshSyncStatus()
|
||||
} finally {
|
||||
@@ -211,19 +220,41 @@ async function runScheduleSync() {
|
||||
async function runRoomsSync() {
|
||||
syncingRooms.value = true
|
||||
syncError.value = ''
|
||||
syncErrorDetails.value = []
|
||||
syncResult.value = null
|
||||
try {
|
||||
syncResult.value = await syncApi.rooms()
|
||||
if (syncResult.value.error) {
|
||||
syncError.value = syncResult.value.error
|
||||
syncErrorDetails.value = syncResult.value.details ?? []
|
||||
addToast?.('Синхронизация аудиторий завершилась с ошибкой.', 'error')
|
||||
} else {
|
||||
addToast?.('Аудитории синхронизированы.', 'success')
|
||||
}
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
syncError.value = err instanceof Error ? err.message : 'Не удалось синхронизировать аудитории.'
|
||||
syncErrorDetails.value = extractApiErrorDetails(err)
|
||||
addToast?.(syncError.value, 'error')
|
||||
} finally {
|
||||
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(() => {
|
||||
void loadData()
|
||||
})
|
||||
@@ -282,6 +313,12 @@ onMounted(() => {
|
||||
<div v-if="syncError || visibleSyncResult?.error" class="sync-error">
|
||||
{{ syncError || visibleSyncResult?.error }}
|
||||
</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">
|
||||
<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-result { font-size: 13px; color: var(--color-text-secondary); }
|
||||
.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 {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid var(--color-border-glass);
|
||||
|
||||
Reference in New Issue
Block a user