/
kochkatech
/
Queue
Обзор
Документация
Войти
/
kochkatech
/
Queue
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
dev
src/Queue.Server/Endpoints/TicketEndpoints.cs
206 строк
9 KB
kochkareal
fix: проверка допустимого статуса перед сменой (state guard) (задача 2.3)
27 июл 2026, 17:33
27 июл 2026, 17:33
092160b
Код
Авторство
О чём код?
using System; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using Queue.Core.Enums; using Queue.Core.Models; using Queue.Data; using Queue.Server.Dtos; using Queue.Server.Hubs; namespace Queue.Server.Endpoints; public static class TicketEndpoints { public static void MapTicketEndpoints(this WebApplication app) { app.MapGet("/api/tickets/queue", async (int windowId, QueueDbContextFactory dbFactory) => { using var db = dbFactory.CreateDbContext(); var window = await db.Windows.Include(w => w.Services).FirstOrDefaultAsync(w => w.Id == windowId); if (window is null) { return Results.NotFound(); } var serviceIds = window.Services.Select(s => s.Id).ToHashSet(); var serviceNames = window.Services.ToDictionary(s => s.Id, s => s.Name); var waiting = await db.Tickets .Where(t => t.Status == TicketStatus.Waiting && serviceIds.Contains(t.ServiceId)) .OrderBy(t => t.CreatedAt) .ToListAsync(); var result = waiting.Select(t => ToDto(t, serviceNames.GetValueOrDefault(t.ServiceId, "—"), null)).ToList(); return Results.Ok(result); }); app.MapGet("/api/tickets/served-today", async (int employeeId, QueueDbContextFactory dbFactory) => { using var db = dbFactory.CreateDbContext(); var today = DateTime.Today; var tickets = await db.Tickets .Where(t => t.EmployeeId == employeeId && t.CreatedAt >= today && (t.Status == TicketStatus.Served || t.Status == TicketStatus.NoShow)) .OrderByDescending(t => t.FinishedAt) .ToListAsync(); var services = await db.Services.ToListAsync(); var serviceNames = services.ToDictionary(s => s.Id, s => s.Name); var result = tickets.Select(t => ToDto(t, serviceNames.GetValueOrDefault(t.ServiceId, "—"), null)).ToList(); return Results.Ok(result); }); app.MapPost("/api/tickets/call", async (CallRequest request, QueueDbContextFactory dbFactory, IHubContext<QueueHub> hub) => { using var db = dbFactory.CreateDbContext(); var window = await db.Windows.Include(w => w.Services).FirstOrDefaultAsync(w => w.Id == request.WindowId); if (window is null) { return Results.NotFound("Окно не найдено"); } var serviceIds = window.Services.Select(s => s.Id).ToHashSet(); // Между чтением "первого ожидающего талона" и записью есть окно гонки: если два // оператора одновременно нажмут "Вызвать следующего" на пересекающихся услугах, // обычный FindAsync+SaveChanges мог бы отдать один и тот же талон обоим. Поэтому здесь // не читаем сущность и не сохраняем через SaveChanges, а "захватываем" талон одним // атомарным UPDATE ... WHERE Status = Waiting: если конкурентный запрос успел забрать // этот же талон первым, ExecuteUpdateAsync вернёт 0 затронутых строк, и мы переходим // к следующему кандидату по порядку очереди. var candidateIds = await db.Tickets .Where(t => t.Status == TicketStatus.Waiting && serviceIds.Contains(t.ServiceId)) .OrderBy(t => t.CreatedAt) .Select(t => t.Id) .ToListAsync(); var calledAt = DateTime.Now; var claimedId = 0; foreach (var candidateId in candidateIds) { var affected = await db.Tickets .Where(t => t.Id == candidateId && t.Status == TicketStatus.Waiting) .ExecuteUpdateAsync(setters => setters .SetProperty(t => t.Status, TicketStatus.Called) .SetProperty(t => t.CalledAt, calledAt) .SetProperty(t => t.WindowId, request.WindowId) .SetProperty(t => t.EmployeeId, request.EmployeeId)); if (affected > 0) { claimedId = candidateId; break; } } if (claimedId == 0) { return Results.Ok((TicketDto?)null); } var ticket = await db.Tickets.FindAsync(claimedId); if (ticket is null) { // Не должно происходить: claimedId только что был обновлён в этой же транзакции. return Results.Ok((TicketDto?)null); } var serviceName = (await db.Services.FindAsync(ticket.ServiceId))?.Name ?? "—"; await hub.Clients.All.SendAsync("QueueChanged"); return Results.Ok(ToDto(ticket, serviceName, window.Name)); }); app.MapPost("/api/tickets/{id:int}/start", (int id, QueueDbContextFactory dbFactory, IHubContext<QueueHub> hub) => ChangeStatusAsync(id, dbFactory, hub, new[] { TicketStatus.Called }, ticket => { ticket.Status = TicketStatus.InService; ticket.StartedAt = DateTime.Now; })); app.MapPost("/api/tickets/{id:int}/finish", (int id, QueueDbContextFactory dbFactory, IHubContext<QueueHub> hub) => ChangeStatusAsync(id, dbFactory, hub, new[] { TicketStatus.InService }, ticket => { ticket.Status = TicketStatus.Served; ticket.FinishedAt = DateTime.Now; })); app.MapPost("/api/tickets/{id:int}/noshow", (int id, QueueDbContextFactory dbFactory, IHubContext<QueueHub> hub) => ChangeStatusAsync(id, dbFactory, hub, new[] { TicketStatus.Called }, ticket => { ticket.Status = TicketStatus.NoShow; ticket.FinishedAt = DateTime.Now; })); app.MapPost("/api/tickets/{id:int}/cancel", (int id, QueueDbContextFactory dbFactory, IHubContext<QueueHub> hub) => ChangeStatusAsync(id, dbFactory, hub, new[] { TicketStatus.Waiting, TicketStatus.Called, TicketStatus.InService }, ticket => { ticket.Status = TicketStatus.Cancelled; ticket.FinishedAt = DateTime.Now; })); app.MapPost("/api/tickets/{id:int}/transfer", async (int id, TransferRequest request, QueueDbContextFactory dbFactory, IHubContext<QueueHub> hub) => { using (var db = dbFactory.CreateDbContext()) { var serviceExists = await db.Services.AnyAsync(s => s.Id == request.NewServiceId && s.IsActive); if (!serviceExists) { return Results.NotFound("Услуга не найдена или неактивна."); } } return await ChangeStatusAsync(id, dbFactory, hub, new[] { TicketStatus.Called, TicketStatus.InService }, ticket => { ticket.ServiceId = request.NewServiceId; ticket.Status = TicketStatus.Waiting; ticket.WindowId = null; ticket.EmployeeId = null; ticket.CalledAt = null; ticket.StartedAt = null; }); }); } private static async Task<IResult> ChangeStatusAsync( int id, QueueDbContextFactory dbFactory, IHubContext<QueueHub> hub, TicketStatus[] allowedFrom, Action<Ticket> apply) { using var db = dbFactory.CreateDbContext(); var ticket = await db.Tickets.FindAsync(id); if (ticket is null) { return Results.NotFound(); } if (!allowedFrom.Contains(ticket.Status)) { return Results.Conflict($"Талон в статусе «{ticket.Status}», операция недопустима."); } apply(ticket); await db.SaveChangesAsync(); var serviceName = (await db.Services.FindAsync(ticket.ServiceId))?.Name ?? "—"; var windowName = ticket.WindowId.HasValue ? (await db.Windows.FindAsync(ticket.WindowId.Value))?.Name : null; await hub.Clients.All.SendAsync("QueueChanged"); return Results.Ok(ToDto(ticket, serviceName, windowName)); } private static TicketDto ToDto(Ticket ticket, string serviceName, string? windowName) => new( ticket.Id, ticket.Number, ticket.ServiceId, serviceName, ticket.WindowId, windowName, ticket.Status.ToString(), ticket.CreatedAt, ticket.CalledAt, ticket.StartedAt, ticket.FinishedAt); }