Перейти к основному содержимому

Практикум — сервис заказов на C#

Разработчику

Практикум, шаг 5 из 8. orders-api оркестрирует резерв в catalog-api. DTO — шаг 3.


Создание проекта

cd OrderDesk
dotnet new web -n orders-api -o orders-api
cd orders-api
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

appsettings.json:

{
"Catalog": {
"BaseUrl": "http://localhost:8100",
"ApiKey": "dev-catalog-key-change-me"
},
"Jwt": {
"Issuer": "orderdesk",
"Audience": "orderdesk-clients",
"SigningKey": "DEV_ONLY_32_CHAR_MINIMUM_SECRET!!"
}
}

HttpClient к каталогу

Services/CatalogClient.cs:

using System.Net.Http.Json;
using System.Text.Json;

public sealed class CatalogClient
{
private readonly HttpClient _http;
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};

public CatalogClient(HttpClient http) => _http = http;

public async Task<CatalogProductDto?> GetProductAsync(string productId, CancellationToken ct)
{
var resp = await _http.GetAsync($"/api/v1/products/{productId}", ct);
if (resp.StatusCode == System.Net.HttpStatusCode.NotFound) return null;
resp.EnsureSuccessStatusCode();
return await resp.Content.ReadFromJsonAsync<CatalogProductDto>(JsonOpts, ct);
}

public async Task<CatalogReservationDto> ReserveAsync(
string productId, int quantity, string orderRef, string idempotencyKey, CancellationToken ct)
{
using var req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/reservations");
req.Headers.Add("Idempotency-Key", idempotencyKey);
req.Content = JsonContent.Create(new
{
productId,
quantity,
orderRef,
}, options: JsonOpts);

var resp = await _http.SendAsync(req, ct);
if ((int)resp.StatusCode == 409)
throw new InsufficientStockException(productId);
resp.EnsureSuccessStatusCode();
return (await resp.Content.ReadFromJsonAsync<CatalogReservationDto>(JsonOpts, ct))!;
}
}

Регистрация в Program.cs:

builder.Services.AddHttpClient<CatalogClient>((sp, client) =>
{
var cfg = sp.GetRequiredService<IConfiguration>().GetSection("Catalog");
client.BaseAddress = new Uri(cfg["BaseUrl"]!);
client.DefaultRequestHeaders.Add("X-Api-Key", cfg["ApiKey"]);
client.Timeout = TimeSpan.FromSeconds(5);
});

Домен и EF Core

Models/Order.cs — статусы Draft, Reserved, Confirmed, Cancelled, Failed.

Data/OrdersDbContext.cs — таблицы Orders, OrderLines.

Services/OrderService.cs — создание заказа:

public async Task<Order> CreateOrderAsync(string userId, CreateOrderRequest request, CancellationToken ct)
{
if (request.Lines.Count == 0)
throw new ValidationException("Order must contain at least one line");

var order = new Order
{
Id = $"ord_{Guid.NewGuid():N}"[..12],
UserId = userId,
Status = OrderStatus.Draft,
CreatedAt = DateTimeOffset.UtcNow,
};

foreach (var line in request.Lines)
{
var product = await _catalog.GetProductAsync(line.ProductId, ct)
?? throw new ValidationException($"Unknown product {line.ProductId}");

var reservation = await _catalog.ReserveAsync(
line.ProductId,
line.Quantity,
order.Id,
idempotencyKey: $"{order.Id}:{line.ProductId}",
ct);

order.Lines.Add(new OrderLine
{
ProductId = line.ProductId,
Quantity = line.Quantity,
UnitPrice = product.Price,
ReservationId = reservation.ReservationId,
});
}

order.Status = OrderStatus.Reserved;
order.Total = order.Lines.Sum(l => l.UnitPrice * l.Quantity);
_db.Orders.Add(order);
await _db.SaveChangesAsync(ct);
await _events.PublishAsync(new OrderStatusEvent(order.Id, order.Status.ToString()));
return order;
}

OrderEventHub подключим в шаге 7.


Minimal API

Program.cs (фрагмент):

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapPost("/api/v1/auth/token", (LoginRequest req, IConfiguration cfg) =>
{
// учебный логин: demo / demo
if (req.Username != "demo" || req.Password != "demo")
return Results.Unauthorized();
var token = JwtHelper.IssueToken(req.Username, cfg);
return Results.Ok(new { access_token = token, token_type = "Bearer", expires_in = 3600 });
});

var orders = app.MapGroup("/api/v1/orders").RequireAuthorization();

orders.MapGet("/", async (HttpContext ctx, OrderService svc) =>
{
var userId = ctx.User.Identity!.Name!;
var list = await svc.ListForUserAsync(userId);
return Results.Ok(list.Select(OrderMapper.ToResponse));
});

orders.MapPost("/", async (CreateOrderRequest body, HttpContext ctx, OrderService svc, CancellationToken ct) =>
{
try
{
var order = await svc.CreateOrderAsync(ctx.User.Identity!.Name!, body, ct);
return Results.Created($"/api/v1/orders/{order.Id}", OrderMapper.ToResponse(order));
}
catch (InsufficientStockException ex)
{
return Results.Conflict(new { title = ex.Message });
}
catch (HttpRequestException)
{
return Results.Json(new { title = "Catalog unavailable" }, statusCode: 502);
}
});

app.Run("http://localhost:5200");

Запуск вместе с каталогом

Терминал 1:

cd OrderDesk\catalog-api
uvicorn app.main:app --port 8100

Терминал 2:

cd OrderDesk\orders-api
dotnet run

Получите JWT и создайте заказ — подробный сценарий в Postman. Безопасность вынесена в шаг 6.


Отмена заказа и компенсация

OrderService.CancelAsync для каждой строки с ReservationId вызывает CatalogClient.DeleteReservationAsync, затем ставит статус cancelled и шлёт WebSocket-событие (см. шаг 7):

public async Task<Order?> CancelAsync(string orderId, string userId, CancellationToken ct)
{
var order = await _db.Orders.Include(o => o.Lines).FirstOrDefaultAsync(o => o.Id == orderId, ct);
if (order is null || order.UserId != userId) return null;
foreach (var line in order.Lines.Where(l => l.ReservationId is not null))
await _catalog.DeleteReservationAsync(line.ReservationId!, ct);
order.Status = OrderStatus.Cancelled;
await _db.SaveChangesAsync(ct);
await _events.PublishAsync(new OrderStatusEvent(order.Id, "cancelled"));
return order;
}

Это пример саги с компенсацией в миниатюре: прямой оркестрации без брокера достаточно для учебного стенда.


Health-check для оркестратора

app.MapGet("/health/live", () => Results.Ok(new { status = "live" }));
app.MapGet("/health/ready", async (CatalogClient catalog, CancellationToken ct) =>
{
try
{
await catalog.PingAsync(ct);
return Results.Ok(new { status = "ready", catalog = "up" });
}
catch
{
return Results.Json(new { status = "degraded", catalog = "down" }, statusCode: 503);
}
});

В CatalogClient добавьте GET /api/v1/products?pageSize=1 как лёгкий ping.


Чек-лист orders-api

  • POST /orders при остановленном каталоге → 502
  • POST /cancel возвращает остаток через catalog DELETE
  • JWT обязателен на /api/v1/orders
  • В логах виден тот же X-Request-Id, что прислал клиент

См. также

Другие статьи этого же раздела в боковом меню (как на странице "О разделе").