From ef6f1f76e7b9d9cf0762ec50e3fc470712bae1fe Mon Sep 17 00:00:00 2001 From: Stepan Date: Tue, 29 Sep 2026 02:08:46 +0500 Subject: [PATCH 1/2] Build reliable billing workflow with transactional outbox and integration tests --- .config/dotnet-tools.json | 7 + .dockerignore | 7 + .gitattributes | 10 + .github/workflows/ci.yml | 23 + .gitignore | 2 + Directory.Build.props | 6 + Dockerfile | 24 + README.md | 84 +++ Subscription & Billing.sln | 11 + .../.config/dotnet-tools.json | 5 - Subscription & Billing/AppDbContext.cs | 34 +- .../SubscriptionActivationConsumer.cs | 41 +- .../SubscriptionWorkflowController.cs | 48 +- .../Controllers/UsersController.cs | 38 ++ .../Controllers/WeatherForecastController.cs | 33 -- .../Dtos/CreateSubscriptionRequest.cs | 17 +- .../Infrastructure/BillingExceptionHandler.cs | 19 + .../Infrastructure/DatabaseHealthCheck.cs | 14 + .../DesignTimeDbContextFactory.cs | 12 + .../20260928205116_InitialBilling.Designer.cs | 334 +++++++++++ .../20260928205116_InitialBilling.cs | 242 ++++++++ .../Migrations/AppDbContextModelSnapshot.cs | 331 +++++++++++ .../Models/IdempotencyRecord.cs | 11 +- Subscription & Billing/Program.cs | 118 ++-- .../Services/PlanCatalog.cs | 17 + .../Services/SubscriptionService.cs | 112 ++-- .../Subscription & Billing.csproj | 20 +- .../Subscription & Billing.http | 22 +- .../Subscription_Billing.md | 561 ------------------ Subscription & Billing/WeatherForecast.cs | 13 - Subscription & Billing/appsettings.json | 8 +- SubscriptionBackend/.gitignore | 0 SubscriptionBackend/Program.cs | 44 -- .../Properties/launchSettings.json | 41 -- .../SubscriptionBackend.csproj | 14 - SubscriptionBackend/SubscriptionBackend.http | 6 - .../appsettings.Development.json | 8 - SubscriptionBackend/appsettings.json | 9 - compose.yaml | 75 +++ docs/architecture.md | 111 ++++ docs/verification.md | 47 ++ global.json | 6 + scripts/load.py | 81 +++ scripts/verify.ps1 | 61 ++ tests/Billing.Tests/Billing.Tests.csproj | 16 + tests/Billing.Tests/BillingFixture.cs | 89 +++ tests/Billing.Tests/BillingTests.cs | 217 +++++++ 47 files changed, 2118 insertions(+), 931 deletions(-) create mode 100644 .config/dotnet-tools.json create mode 100644 .dockerignore create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 Directory.Build.props create mode 100644 Dockerfile create mode 100644 README.md delete mode 100644 Subscription & Billing/.config/dotnet-tools.json create mode 100644 Subscription & Billing/Controllers/UsersController.cs delete mode 100644 Subscription & Billing/Controllers/WeatherForecastController.cs create mode 100644 Subscription & Billing/Infrastructure/BillingExceptionHandler.cs create mode 100644 Subscription & Billing/Infrastructure/DatabaseHealthCheck.cs create mode 100644 Subscription & Billing/Infrastructure/DesignTimeDbContextFactory.cs create mode 100644 Subscription & Billing/Migrations/20260928205116_InitialBilling.Designer.cs create mode 100644 Subscription & Billing/Migrations/20260928205116_InitialBilling.cs create mode 100644 Subscription & Billing/Migrations/AppDbContextModelSnapshot.cs create mode 100644 Subscription & Billing/Services/PlanCatalog.cs delete mode 100644 Subscription & Billing/Subscription_Billing.md delete mode 100644 Subscription & Billing/WeatherForecast.cs delete mode 100644 SubscriptionBackend/.gitignore delete mode 100644 SubscriptionBackend/Program.cs delete mode 100644 SubscriptionBackend/Properties/launchSettings.json delete mode 100644 SubscriptionBackend/SubscriptionBackend.csproj delete mode 100644 SubscriptionBackend/SubscriptionBackend.http delete mode 100644 SubscriptionBackend/appsettings.Development.json delete mode 100644 SubscriptionBackend/appsettings.json create mode 100644 compose.yaml create mode 100644 docs/architecture.md create mode 100644 docs/verification.md create mode 100644 global.json create mode 100644 scripts/load.py create mode 100644 scripts/verify.ps1 create mode 100644 tests/Billing.Tests/Billing.Tests.csproj create mode 100644 tests/Billing.Tests/BillingFixture.cs create mode 100644 tests/Billing.Tests/BillingTests.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..5ff8348 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,7 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { "version": "8.0.11", "commands": ["dotnet-ef"] } + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ffa2c06 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +**/bin +**/obj +.git +.vs +.tools +.env +artifacts diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1630640 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +* text=auto +*.cs text eol=lf +*.csproj text eol=lf +*.json text eol=lf +*.md text eol=lf +*.yaml text eol=lf +*.yml text eol=lf +*.ps1 text eol=lf +*.py text eol=lf +Dockerfile text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6bfcfb3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: billing-ci +on: [push, pull_request] +permissions: + contents: read +jobs: + integration: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - name: Start full stack and apply migrations + run: docker compose up --build -d + - name: Test against PostgreSQL and RabbitMQ + run: docker compose --profile test run --build --rm tests + - name: Smoke and outage recovery + shell: pwsh + run: ./scripts/verify.ps1 + - name: Logs + if: failure() + run: docker compose logs --no-color + - name: Cleanup + if: always() + run: docker compose down -v diff --git a/.gitignore b/.gitignore index 104b544..a7b6dce 100644 --- a/.gitignore +++ b/.gitignore @@ -482,3 +482,5 @@ $RECYCLE.BIN/ # Vim temporary swap files *.swp + +.tools/ diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..ca063ef --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,6 @@ + + + true + latest + + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a3d971f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS restore +WORKDIR /src +COPY Directory.Build.props global.json ./ +COPY ["Subscription & Billing/Subscription & Billing.csproj", "Subscription & Billing/"] +RUN dotnet restore "Subscription & Billing/Subscription & Billing.csproj" + +FROM restore AS test-restore +COPY tests/Billing.Tests/Billing.Tests.csproj tests/Billing.Tests/ +RUN dotnet restore tests/Billing.Tests/Billing.Tests.csproj + +FROM test-restore AS test +COPY . . +ENTRYPOINT ["dotnet", "test", "tests/Billing.Tests/Billing.Tests.csproj", "-c", "Release", "--no-restore", "--logger", "console;verbosity=normal"] + +FROM restore AS build +COPY . . +RUN dotnet publish "Subscription & Billing/Subscription & Billing.csproj" -c Release --no-restore -o /app /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime +WORKDIR /app +COPY --from=build /app . +USER app +EXPOSE 8080 +ENTRYPOINT ["dotnet", "Billing.dll"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..2b176a0 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# Subscription Billing + +ASP.NET Core backend: запись оплаты, создание подписки и асинхронная активация. +PostgreSQL 16 · RabbitMQ 4 · EF Core 8 · MassTransit 8 · .NET 8. + +## Запуск + +Нужен Docker с Compose. Из корня репозитория: + +```sh +docker compose up --build -d +``` + +Открой [Swagger](http://localhost:8080/swagger). Compose запускает БД, брокер, +миграции, API и worker. Первая сборка скачивает образы. Данные сохраняются в volumes. +Остановка: `docker compose down`. Локальные пароли заданы в Compose; +их можно заменить через `.env` (`POSTGRES_PASSWORD`, `RABBITMQ_PASSWORD`). + +## Архитектура + +```mermaid +flowchart LR + C[Client + Idempotency-Key] --> A[API: validation + concurrency limit] + A --> S[SubscriptionService] + S -->|one transaction| DB[(PostgreSQL: subscription + payment + response + outbox)] + DB --> D[MassTransit outbox delivery] + D -->|publisher confirm| R[RabbitMQ durable queue] + R --> W[Worker: retry + inbox] + W -->|conditional activation| DB + W -->|failed after retries| E[Error queue] +``` + +- **Деньги и событие атомарны:** outbox записывается в той же транзакции. + При недоступном брокере запрос сохраняется, доставка продолжится после восстановления. +- **Параллельные повторы безопасны:** PostgreSQL advisory lock по ключу, + сохранённый ответ, `409` при смене тела. Нет блокировок в памяти одного сервера. +- **Доставка at-least-once:** inbox и условный UPDATE защищают активацию от дублей. + Необработанные события после трёх повторов попадают в `subscription-activation_error`. +- **Контроль нагрузки:** максимум 64 одновременных API-запроса, затем `429`; + prefetch 32, 8 обработчиков на worker; индекс и cursor pagination с лимитом 100. +- **Эксплуатация:** отдельные API/worker, миграции перед запуском, контейнер без root, + JSON-логи, `traceId` в ProblemDetails, `/health/live` и `/health/ready`. + +## Попробовать + +1. `POST /api/users`: `{"name":"Stepan","email":"stepan@example.com"}`. +2. `POST /api/subscriptions/create`: `{"userId":1,"plan":"basic"}`, + заголовок `Idempotency-Key: 75be42df-1323-48c2-a317-55bb64445a1b`. +3. Ответ `202` и `Location`; `GET /api/subscriptions/1` покажет `isActive: true` + после обработки события. Повтор шага 2 вернёт исходный ответ без второй оплаты. + +Цены задаёт сервер: basic = 990 RUB, pro = 1990 RUB. Список: +`GET /api/subscriptions?userId=1&afterId=0&limit=20`. Полный контракт в Swagger. + +## Проверка + +```sh +docker compose --profile test run --build --rm tests +``` + +xUnit + WebApplicationFactory проверяют настоящий PostgreSQL и RabbitMQ: +20 параллельных повторов, откат после вставки платежа, конфликт ключа, +валидацию, серверную цену, пагинацию, дубли событий и активацию. +Тесты используют отдельную БД `billing_tests` и отдельный exchange/queue. + +`pwsh ./scripts/verify.ps1` проверяет полный стек, отключает брокер, +перезапускает API и проверяет доставку сохранённого события после восстановления. +GitHub Actions запускает обе проверки. [Архитектурные решения и масштабирование](docs/architecture.md). + +Мини-бенчмарк записи (Python 3, без пакетов): +`python scripts/load.py --requests 200 --concurrency 20`. +Он измеряет p50/p95 и число принятых запросов в секунду, затем проверяет активацию. +[Результат проверок и условия замера](docs/verification.md). + +## Границы проекта + +Это демонстрация надёжности backend, оплата симулируется: платёжного провайдера, +авторизации, возвратов и периодических списаний пока нет. Compose открыт только +на localhost. Для публичного сервиса нужны auth, проверенные webhooks провайдера, +TLS и управление секретами. Высокая нагрузка требует замеров, а не обещаний RPS. + +Контракт изменён: клиентский `amount` удалён, POST теперь возвращает `202`. +Начальная миграция предназначена для новой БД; старую БД, созданную через +`EnsureCreated`, нужно переносить отдельно с резервной копией. diff --git a/Subscription & Billing.sln b/Subscription & Billing.sln index 4805cc5..8232464 100644 --- a/Subscription & Billing.sln +++ b/Subscription & Billing.sln @@ -5,6 +5,10 @@ VisualStudioVersion = 17.11.35222.181 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Subscription & Billing", "Subscription & Billing\Subscription & Billing.csproj", "{355B9D45-5658-43C0-B86D-5135298F19E9}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{4127394C-64CD-400E-9F55-570D4DE21232}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Billing.Tests", "tests\Billing.Tests\Billing.Tests.csproj", "{321D0816-D5EA-4EAC-B948-169278FD8EC1}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +19,10 @@ Global {355B9D45-5658-43C0-B86D-5135298F19E9}.Debug|Any CPU.Build.0 = Debug|Any CPU {355B9D45-5658-43C0-B86D-5135298F19E9}.Release|Any CPU.ActiveCfg = Release|Any CPU {355B9D45-5658-43C0-B86D-5135298F19E9}.Release|Any CPU.Build.0 = Release|Any CPU + {321D0816-D5EA-4EAC-B948-169278FD8EC1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {321D0816-D5EA-4EAC-B948-169278FD8EC1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {321D0816-D5EA-4EAC-B948-169278FD8EC1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {321D0816-D5EA-4EAC-B948-169278FD8EC1}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -22,4 +30,7 @@ Global GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A288BAF6-7662-485E-AB6E-A4FCF8F7AA78} EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {321D0816-D5EA-4EAC-B948-169278FD8EC1} = {4127394C-64CD-400E-9F55-570D4DE21232} + EndGlobalSection EndGlobal diff --git a/Subscription & Billing/.config/dotnet-tools.json b/Subscription & Billing/.config/dotnet-tools.json deleted file mode 100644 index b0e38ab..0000000 --- a/Subscription & Billing/.config/dotnet-tools.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "version": 1, - "isRoot": true, - "tools": {} -} \ No newline at end of file diff --git a/Subscription & Billing/AppDbContext.cs b/Subscription & Billing/AppDbContext.cs index b9d03fc..91576f3 100644 --- a/Subscription & Billing/AppDbContext.cs +++ b/Subscription & Billing/AppDbContext.cs @@ -1,17 +1,31 @@ +using MassTransit; using Microsoft.EntityFrameworkCore; using Subscription_Backend.Models; -using System.Collections.Generic; -namespace Subscription_Backend -{ - public class AppDbContext : DbContext - { - public AppDbContext(DbContextOptions options) : base(options) { } +namespace Subscription_Backend; - public DbSet Users => Set(); - public DbSet Subscriptions => Set(); - public DbSet Payments => Set(); - public DbSet IdempotencyRecords => Set(); +public sealed class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Users => Set(); + public DbSet Subscriptions => Set(); + public DbSet Payments => Set(); + public DbSet IdempotencyRecords => Set(); + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().Property(x => x.Name).HasMaxLength(120); + model.Entity().Property(x => x.Email).HasMaxLength(254); + model.Entity().HasIndex(x => x.Email).IsUnique(); + model.Entity().Property(x => x.Plan).HasMaxLength(32); + model.Entity().HasOne().WithMany().HasForeignKey(x => x.UserId); + model.Entity().HasIndex(x => new { x.UserId, x.Id }); + model.Entity().HasIndex(x => x.SubscriptionId).IsUnique(); + model.Entity().Property(x => x.Amount).HasPrecision(18, 2); + model.Entity().ToTable(t => t.HasCheckConstraint("CK_Payment_Amount", "\"Amount\" > 0")); + model.Entity().HasKey(x => x.Key); + model.Entity().Property(x => x.RequestHash).HasMaxLength(64); + model.AddInboxStateEntity(); + model.AddOutboxMessageEntity(); + model.AddOutboxStateEntity(); } } diff --git a/Subscription & Billing/Consumers/SubscriptionActivationConsumer.cs b/Subscription & Billing/Consumers/SubscriptionActivationConsumer.cs index d0b3683..d5a80c1 100644 --- a/Subscription & Billing/Consumers/SubscriptionActivationConsumer.cs +++ b/Subscription & Billing/Consumers/SubscriptionActivationConsumer.cs @@ -1,40 +1,21 @@ using MassTransit; using Microsoft.EntityFrameworkCore; using Subscription_Backend.Messages; -using Subscription_Backend.Models; namespace Subscription_Backend.Consumers; -public class SubscriptionActivationConsumer : IConsumer +public sealed class SubscriptionActivationConsumer(AppDbContext db, + ILogger logger) : IConsumer { - private readonly AppDbContext _db; - private readonly ILogger _logger; - - public SubscriptionActivationConsumer(AppDbContext db, ILogger logger) - { - _db = db; - _logger = logger; - } - public async Task Consume(ConsumeContext context) { - var message = context.Message; - _logger.LogInformation($"[Worker] Ïîëó÷åíî ñîáûòèå îïëàòû äëÿ ïîäïèñêè {message.SubscriptionId}"); - - // Óáðàëè Random Exception. - // Åñëè áàçà óïàäåò ñàìà ïî ñåáå — Retry ñðàáîòàåò àâòîìàòè÷åñêè. - - var subscription = await _db.Subscriptions.FindAsync(message.SubscriptionId); - - if (subscription == null) - { - _logger.LogError($"Ïîäïèñêà {message.SubscriptionId} íå íàéäåíà!"); - return; - } - - subscription.IsActive = true; - await _db.SaveChangesAsync(); - - _logger.LogInformation($"[Worker] Ïîäïèñêà {message.SubscriptionId} óñïåøíî àêòèâèðîâàíà!"); + var id = context.Message.SubscriptionId; + if (!await db.Payments.AnyAsync(x => x.SubscriptionId == id && x.Amount == context.Message.Amount, + context.CancellationToken)) + throw new InvalidOperationException($"Payment for subscription {id} is missing or inconsistent."); + // Also safe for different message IDs carrying the same business event. + var changed = await db.Subscriptions.Where(x => x.Id == id && !x.IsActive) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.IsActive, true), context.CancellationToken); + logger.LogInformation("Activation processed for {SubscriptionId}; changed {Changed}", id, changed); } -} \ No newline at end of file +} diff --git a/Subscription & Billing/Controllers/SubscriptionWorkflowController.cs b/Subscription & Billing/Controllers/SubscriptionWorkflowController.cs index 27eb73b..60f2bd6 100644 --- a/Subscription & Billing/Controllers/SubscriptionWorkflowController.cs +++ b/Subscription & Billing/Controllers/SubscriptionWorkflowController.cs @@ -1,34 +1,46 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using Subscription_Backend.Dtos; -using Subscription_Backend.Models; using Subscription_Backend.Services; namespace Subscription_Backend.Controllers; [ApiController] [Route("api/subscriptions")] -public class SubscriptionWorkflowController : ControllerBase +public sealed class SubscriptionWorkflowController(SubscriptionService service, AppDbContext db) : ControllerBase { - private readonly SubscriptionService _service; - - public SubscriptionWorkflowController(SubscriptionService service) + [HttpPost("create")] + [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Create(CreateSubscriptionRequest request, + [FromHeader(Name = "Idempotency-Key")] Guid key, CancellationToken ct) { - _service = service; + if (key == Guid.Empty) + return Problem(statusCode: 400, detail: "A non-empty UUID Idempotency-Key header is required."); + var result = await service.CreateSubscriptionAsync(request, key, ct); + return AcceptedAtAction(nameof(Get), new { id = result.Id }, result); } - [HttpPost("create")] - public async Task> Create( - CreateSubscriptionRequest request, - [FromHeader(Name = "Idempotency-Key")] Guid idempotencyKey) + [HttpGet("{id:int}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Get(int id, CancellationToken ct) { - if (idempotencyKey == Guid.Empty) - return BadRequest("Idempotency-Key header is required"); - - var subscription = await _service.CreateSubscriptionAsync( - request, - idempotencyKey); - - return Ok(subscription); + var result = await db.Subscriptions.AsNoTracking().Where(x => x.Id == id) + .Select(x => new SubscriptionResponse(x.Id, x.UserId, x.Plan, x.IsActive)).SingleOrDefaultAsync(ct); + return result is null ? NotFound() : Ok(result); } + [HttpGet] + public async Task>> List( + [FromQuery] int userId, [FromQuery] int afterId = 0, [FromQuery] int limit = 20, CancellationToken ct = default) + { + if (userId < 1 || afterId < 0 || limit is < 1 or > 100) + return Problem(statusCode: 400, detail: "userId > 0, afterId >= 0, limit between 1 and 100 required."); + return Ok(await db.Subscriptions.AsNoTracking().Where(x => x.UserId == userId && x.Id > afterId) + .OrderBy(x => x.Id).Take(limit) + .Select(x => new SubscriptionResponse(x.Id, x.UserId, x.Plan, x.IsActive)).ToListAsync(ct)); + } } diff --git a/Subscription & Billing/Controllers/UsersController.cs b/Subscription & Billing/Controllers/UsersController.cs new file mode 100644 index 0000000..12cb813 --- /dev/null +++ b/Subscription & Billing/Controllers/UsersController.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Subscription_Backend.Dtos; +using Subscription_Backend.Models; + +namespace Subscription_Backend.Controllers; + +[ApiController] +[Route("api/users")] +public sealed class UsersController(AppDbContext db) : ControllerBase +{ + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> Create(CreateUserRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.Name)) + return Problem(statusCode: 400, detail: "Name must not be blank."); + var user = new User { Name = request.Name.Trim(), Email = request.Email.Trim().ToLowerInvariant() }; + db.Users.Add(user); + try { await db.SaveChangesAsync(ct); } + catch (DbUpdateException e) when (e.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation }) + { return Problem(statusCode: 409, detail: "Email is already registered."); } + return CreatedAtAction(nameof(Get), new { id = user.Id }, new UserResponse(user.Id, user.Name, user.Email)); + } + + [HttpGet("{id:int}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Get(int id, CancellationToken ct) + { + var user = await db.Users.AsNoTracking().Where(x => x.Id == id) + .Select(x => new UserResponse(x.Id, x.Name, x.Email)).SingleOrDefaultAsync(ct); + return user is null ? NotFound() : Ok(user); + } +} diff --git a/Subscription & Billing/Controllers/WeatherForecastController.cs b/Subscription & Billing/Controllers/WeatherForecastController.cs deleted file mode 100644 index 65c060f..0000000 --- a/Subscription & Billing/Controllers/WeatherForecastController.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace Subscription___Billing.Controllers -{ - [ApiController] - [Route("[controller]")] - public class WeatherForecastController : ControllerBase - { - private static readonly string[] Summaries = new[] - { - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" - }; - - private readonly ILogger _logger; - - public WeatherForecastController(ILogger logger) - { - _logger = logger; - } - - [HttpGet(Name = "GetWeatherForecast")] - public IEnumerable Get() - { - return Enumerable.Range(1, 5).Select(index => new WeatherForecast - { - Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - TemperatureC = Random.Shared.Next(-20, 55), - Summary = Summaries[Random.Shared.Next(Summaries.Length)] - }) - .ToArray(); - } - } -} diff --git a/Subscription & Billing/Dtos/CreateSubscriptionRequest.cs b/Subscription & Billing/Dtos/CreateSubscriptionRequest.cs index 30561f4..f5f5b46 100644 --- a/Subscription & Billing/Dtos/CreateSubscriptionRequest.cs +++ b/Subscription & Billing/Dtos/CreateSubscriptionRequest.cs @@ -1,8 +1,17 @@ +using System.ComponentModel.DataAnnotations; + namespace Subscription_Backend.Dtos; -public class CreateSubscriptionRequest +public sealed record CreateSubscriptionRequest { - public int UserId { get; set; } - public string Plan { get; set; } = null!; - public decimal Amount { get; set; } + [Range(1, int.MaxValue)] + public int UserId { get; init; } + [Required, RegularExpression("^(basic|pro)$")] + public string Plan { get; init; } = ""; } + +public sealed record SubscriptionResponse(int Id, int UserId, string Plan, bool IsActive); +public sealed record CreateUserRequest( + [Required, StringLength(120, MinimumLength = 1)] string Name, + [Required, EmailAddress, StringLength(254)] string Email); +public sealed record UserResponse(int Id, string Name, string Email); diff --git a/Subscription & Billing/Infrastructure/BillingExceptionHandler.cs b/Subscription & Billing/Infrastructure/BillingExceptionHandler.cs new file mode 100644 index 0000000..48558b5 --- /dev/null +++ b/Subscription & Billing/Infrastructure/BillingExceptionHandler.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; +using Subscription_Backend.Services; + +namespace Subscription_Backend.Infrastructure; + +public sealed class BillingExceptionHandler(IProblemDetailsService problems) : IExceptionHandler +{ + public async ValueTask TryHandleAsync(HttpContext context, Exception exception, CancellationToken ct) + { + if (exception is not BillingException billing) return false; + context.Response.StatusCode = billing.StatusCode; + return await problems.TryWriteAsync(new ProblemDetailsContext + { + HttpContext = context, + ProblemDetails = new ProblemDetails { Status = billing.StatusCode, Title = "Billing request rejected", Detail = billing.Message } + }); + } +} diff --git a/Subscription & Billing/Infrastructure/DatabaseHealthCheck.cs b/Subscription & Billing/Infrastructure/DatabaseHealthCheck.cs new file mode 100644 index 0000000..f802800 --- /dev/null +++ b/Subscription & Billing/Infrastructure/DatabaseHealthCheck.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Subscription_Backend.Infrastructure; + +public sealed class DatabaseHealthCheck(IServiceScopeFactory scopes) : IHealthCheck +{ + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken ct = default) + { + await using var scope = scopes.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.Database.CanConnectAsync(ct) + ? HealthCheckResult.Healthy() : HealthCheckResult.Unhealthy("Database unavailable."); + } +} diff --git a/Subscription & Billing/Infrastructure/DesignTimeDbContextFactory.cs b/Subscription & Billing/Infrastructure/DesignTimeDbContextFactory.cs new file mode 100644 index 0000000..645cb19 --- /dev/null +++ b/Subscription & Billing/Infrastructure/DesignTimeDbContextFactory.cs @@ -0,0 +1,12 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Subscription_Backend.Infrastructure; + +public sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory +{ + public AppDbContext CreateDbContext(string[] args) => new(new DbContextOptionsBuilder() + .UseNpgsql(Environment.GetEnvironmentVariable("ConnectionStrings__DefaultConnection") + ?? "Host=localhost;Database=billing;Username=billing") + .Options); +} diff --git a/Subscription & Billing/Migrations/20260928205116_InitialBilling.Designer.cs b/Subscription & Billing/Migrations/20260928205116_InitialBilling.Designer.cs new file mode 100644 index 0000000..f6aab28 --- /dev/null +++ b/Subscription & Billing/Migrations/20260928205116_InitialBilling.Designer.cs @@ -0,0 +1,334 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Subscription_Backend; + +#nullable disable + +namespace Subscription_Backend.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260928205116_InitialBilling")] + partial class InitialBilling + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + + modelBuilder.Entity("Subscription_Backend.Models.IdempotencyRecord", b => + { + b.Property("Key") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Response") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Key"); + + b.ToTable("IdempotencyRecords"); + }); + + modelBuilder.Entity("Subscription_Backend.Models.Payment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("PaidAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubscriptionId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId") + .IsUnique(); + + b.ToTable("Payments", t => + { + t.HasCheckConstraint("CK_Payment_Amount", "\"Amount\" > 0"); + }); + }); + + modelBuilder.Entity("Subscription_Backend.Models.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Id"); + + b.ToTable("Subscriptions"); + }); + + modelBuilder.Entity("Subscription_Backend.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Subscription_Backend.Models.Payment", b => + { + b.HasOne("Subscription_Backend.Models.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("Subscription_Backend.Models.Subscription", b => + { + b.HasOne("Subscription_Backend.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Subscription & Billing/Migrations/20260928205116_InitialBilling.cs b/Subscription & Billing/Migrations/20260928205116_InitialBilling.cs new file mode 100644 index 0000000..6c072dc --- /dev/null +++ b/Subscription & Billing/Migrations/20260928205116_InitialBilling.cs @@ -0,0 +1,242 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Subscription_Backend.Migrations +{ + /// + public partial class InitialBilling : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "IdempotencyRecords", + columns: table => new + { + Key = table.Column(type: "uuid", nullable: false), + RequestHash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Response = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_IdempotencyRecords", x => x.Key); + }); + + migrationBuilder.CreateTable( + name: "InboxState", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + MessageId = table.Column(type: "uuid", nullable: false), + ConsumerId = table.Column(type: "uuid", nullable: false), + LockId = table.Column(type: "uuid", nullable: false), + RowVersion = table.Column(type: "bytea", rowVersion: true, nullable: true), + Received = table.Column(type: "timestamp with time zone", nullable: false), + ReceiveCount = table.Column(type: "integer", nullable: false), + ExpirationTime = table.Column(type: "timestamp with time zone", nullable: true), + Consumed = table.Column(type: "timestamp with time zone", nullable: true), + Delivered = table.Column(type: "timestamp with time zone", nullable: true), + LastSequenceNumber = table.Column(type: "bigint", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_InboxState", x => x.Id); + table.UniqueConstraint("AK_InboxState_MessageId_ConsumerId", x => new { x.MessageId, x.ConsumerId }); + }); + + migrationBuilder.CreateTable( + name: "OutboxState", + columns: table => new + { + OutboxId = table.Column(type: "uuid", nullable: false), + LockId = table.Column(type: "uuid", nullable: false), + RowVersion = table.Column(type: "bytea", rowVersion: true, nullable: true), + Created = table.Column(type: "timestamp with time zone", nullable: false), + Delivered = table.Column(type: "timestamp with time zone", nullable: true), + LastSequenceNumber = table.Column(type: "bigint", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OutboxState", x => x.OutboxId); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + Email = table.Column(type: "character varying(254)", maxLength: 254, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "OutboxMessage", + columns: table => new + { + SequenceNumber = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + EnqueueTime = table.Column(type: "timestamp with time zone", nullable: true), + SentTime = table.Column(type: "timestamp with time zone", nullable: false), + Headers = table.Column(type: "text", nullable: true), + Properties = table.Column(type: "text", nullable: true), + InboxMessageId = table.Column(type: "uuid", nullable: true), + InboxConsumerId = table.Column(type: "uuid", nullable: true), + OutboxId = table.Column(type: "uuid", nullable: true), + MessageId = table.Column(type: "uuid", nullable: false), + ContentType = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + MessageType = table.Column(type: "text", nullable: false), + Body = table.Column(type: "text", nullable: false), + ConversationId = table.Column(type: "uuid", nullable: true), + CorrelationId = table.Column(type: "uuid", nullable: true), + InitiatorId = table.Column(type: "uuid", nullable: true), + RequestId = table.Column(type: "uuid", nullable: true), + SourceAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + DestinationAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ResponseAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + FaultAddress = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ExpirationTime = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OutboxMessage", x => x.SequenceNumber); + table.ForeignKey( + name: "FK_OutboxMessage_InboxState_InboxMessageId_InboxConsumerId", + columns: x => new { x.InboxMessageId, x.InboxConsumerId }, + principalTable: "InboxState", + principalColumns: new[] { "MessageId", "ConsumerId" }); + table.ForeignKey( + name: "FK_OutboxMessage_OutboxState_OutboxId", + column: x => x.OutboxId, + principalTable: "OutboxState", + principalColumn: "OutboxId"); + }); + + migrationBuilder.CreateTable( + name: "Subscriptions", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "integer", nullable: false), + Plan = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + IsActive = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Subscriptions", x => x.Id); + table.ForeignKey( + name: "FK_Subscriptions_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Payments", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + SubscriptionId = table.Column(type: "integer", nullable: false), + Amount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false), + PaidAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Payments", x => x.Id); + table.CheckConstraint("CK_Payment_Amount", "\"Amount\" > 0"); + table.ForeignKey( + name: "FK_Payments_Subscriptions_SubscriptionId", + column: x => x.SubscriptionId, + principalTable: "Subscriptions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_InboxState_Delivered", + table: "InboxState", + column: "Delivered"); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessage_EnqueueTime", + table: "OutboxMessage", + column: "EnqueueTime"); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessage_ExpirationTime", + table: "OutboxMessage", + column: "ExpirationTime"); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessage_InboxMessageId_InboxConsumerId_SequenceNumber", + table: "OutboxMessage", + columns: new[] { "InboxMessageId", "InboxConsumerId", "SequenceNumber" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessage_OutboxId_SequenceNumber", + table: "OutboxMessage", + columns: new[] { "OutboxId", "SequenceNumber" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OutboxState_Created", + table: "OutboxState", + column: "Created"); + + migrationBuilder.CreateIndex( + name: "IX_Payments_SubscriptionId", + table: "Payments", + column: "SubscriptionId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Subscriptions_UserId_Id", + table: "Subscriptions", + columns: new[] { "UserId", "Id" }); + + migrationBuilder.CreateIndex( + name: "IX_Users_Email", + table: "Users", + column: "Email", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "IdempotencyRecords"); + + migrationBuilder.DropTable( + name: "OutboxMessage"); + + migrationBuilder.DropTable( + name: "Payments"); + + migrationBuilder.DropTable( + name: "InboxState"); + + migrationBuilder.DropTable( + name: "OutboxState"); + + migrationBuilder.DropTable( + name: "Subscriptions"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/Subscription & Billing/Migrations/AppDbContextModelSnapshot.cs b/Subscription & Billing/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..5ee0b4c --- /dev/null +++ b/Subscription & Billing/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,331 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Subscription_Backend; + +#nullable disable + +namespace Subscription_Backend.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + + modelBuilder.Entity("Subscription_Backend.Models.IdempotencyRecord", b => + { + b.Property("Key") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Response") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Key"); + + b.ToTable("IdempotencyRecords"); + }); + + modelBuilder.Entity("Subscription_Backend.Models.Payment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("PaidAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubscriptionId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId") + .IsUnique(); + + b.ToTable("Payments", t => + { + t.HasCheckConstraint("CK_Payment_Amount", "\"Amount\" > 0"); + }); + }); + + modelBuilder.Entity("Subscription_Backend.Models.Subscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Id"); + + b.ToTable("Subscriptions"); + }); + + modelBuilder.Entity("Subscription_Backend.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Subscription_Backend.Models.Payment", b => + { + b.HasOne("Subscription_Backend.Models.Subscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("Subscription_Backend.Models.Subscription", b => + { + b.HasOne("Subscription_Backend.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Subscription & Billing/Models/IdempotencyRecord.cs b/Subscription & Billing/Models/IdempotencyRecord.cs index b26f58a..9c5c841 100644 --- a/Subscription & Billing/Models/IdempotencyRecord.cs +++ b/Subscription & Billing/Models/IdempotencyRecord.cs @@ -1,14 +1,9 @@ -using System.ComponentModel.DataAnnotations; - namespace Subscription_Backend.Models; -public class IdempotencyRecord +public sealed class IdempotencyRecord { - [Key] public Guid Key { get; set; } - - public string Endpoint { get; set; } = null!; + public string RequestHash { get; set; } = null!; public string Response { get; set; } = null!; - public int StatusCode { get; set; } public DateTime CreatedAt { get; set; } -} \ No newline at end of file +} diff --git a/Subscription & Billing/Program.cs b/Subscription & Billing/Program.cs index ad573d7..b1942be 100644 --- a/Subscription & Billing/Program.cs +++ b/Subscription & Billing/Program.cs @@ -1,62 +1,106 @@ -using MassTransit; +using System.Threading.RateLimiting; +using MassTransit; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.EntityFrameworkCore; using Subscription_Backend; using Subscription_Backend.Consumers; +using Subscription_Backend.Infrastructure; using Subscription_Backend.Services; var builder = WebApplication.CreateBuilder(args); +var role = builder.Configuration["ServiceRole"] ?? "All"; +if (role is not ("Api" or "Worker" or "All" or "Migrate")) + throw new InvalidOperationException("ServiceRole must be Api, Worker, All or Migrate."); +var connection = builder.Configuration.GetConnectionString("DefaultConnection") + ?? throw new InvalidOperationException("ConnectionStrings__DefaultConnection is required."); +builder.Services.AddDbContext(o => o.UseNpgsql(connection)); -builder.Services.AddControllers(); - -builder.Services.AddDbContext(options => - options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"), - npgsqlOptions => npgsqlOptions.EnableRetryOnFailure( - maxRetryCount: 5, - maxRetryDelay: TimeSpan.FromSeconds(10), - errorCodesToAdd: null) - )); +if (role == "Migrate") +{ + var migrationApp = builder.Build(); + await using var scope = migrationApp.Services.CreateAsyncScope(); + await scope.ServiceProvider.GetRequiredService().Database.MigrateAsync(); + await migrationApp.DisposeAsync(); + return; +} +builder.Services.AddControllers().AddJsonOptions(o => + o.JsonSerializerOptions.UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Disallow); builder.Services.AddScoped(); - +builder.Services.AddProblemDetails(o => o.CustomizeProblemDetails = ctx => + ctx.ProblemDetails.Extensions["traceId"] = ctx.HttpContext.TraceIdentifier); +builder.Services.AddExceptionHandler(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +builder.Logging.ClearProviders(); +builder.Logging.AddJsonConsole(); +builder.Services.AddRateLimiter(o => +{ + o.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + o.OnRejected = async (context, _) => await Results.Problem( + statusCode: 429, detail: "Too many concurrent requests. Retry with backoff and the same idempotency key.") + .ExecuteAsync(context.HttpContext); + o.AddConcurrencyLimiter("api", options => + { + options.PermitLimit = builder.Configuration.GetValue("Api:ConcurrencyLimit", 64); + options.QueueLimit = 0; + options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; + }); +}); +builder.Services.AddHealthChecks().AddCheck("postgres", tags: ["ready"]); +builder.Services.Configure(o => +{ + // The bus outbox lets HTTP writes succeed while the broker is unavailable. + o.WaitUntilStarted = false; + o.StopTimeout = TimeSpan.FromSeconds(20); +}); builder.Services.AddMassTransit(x => { - x.AddConsumer(); - + if (role is "Worker" or "All") x.AddConsumer(); + x.AddEntityFrameworkOutbox(o => + { + o.UsePostgres(); + o.QueryDelay = TimeSpan.FromSeconds(1); + o.UseBusOutbox(); + }); x.UsingRabbitMq((context, cfg) => { - cfg.Host("localhost", "/", h => - { - h.Username("guest"); - h.Password("guest"); - }); - - cfg.UseMessageRetry(r => + cfg.Host(builder.Configuration["RabbitMq:Host"] ?? "localhost", "/", h => { - r.Intervals(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10)); + h.Username(builder.Configuration["RabbitMq:Username"] ?? "billing"); + h.Password(builder.Configuration["RabbitMq:Password"] ?? throw new InvalidOperationException("RabbitMq__Password is required.")); }); - - cfg.ConfigureEndpoints(context); + // Declare the durable destination even when API starts before a worker. + var queue = builder.Configuration["RabbitMq:ActivationQueue"] ?? "subscription-activation"; + cfg.Message(m => m.SetEntityName(queue + "-paid")); + cfg.Publish(p => p.BindQueue(queue, queue)); + if (role is "Worker" or "All") + cfg.ReceiveEndpoint(builder.Configuration["RabbitMq:ActivationQueue"] ?? "subscription-activation", e => + { + e.PrefetchCount = 32; + e.ConcurrentMessageLimit = 8; + e.UseMessageRetry(r => r.Intervals(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(5))); + e.UseEntityFrameworkOutbox(context); + e.ConfigureConsumer(context); + }); }); }); -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); - var app = builder.Build(); - -using (var scope = app.Services.CreateScope()) +app.UseExceptionHandler(); +app.UseRateLimiter(); +app.MapHealthChecks("/health/live", new HealthCheckOptions { Predicate = _ => false }); +app.MapHealthChecks("/health/ready", new HealthCheckOptions { - var db = scope.ServiceProvider.GetRequiredService(); - db.Database.EnsureCreated(); -} - -if (app.Environment.IsDevelopment()) + Predicate = check => check.Tags.Contains("ready") || check.Tags.Contains("masstransit") +}); +if (role is "Api" or "All") { app.UseSwagger(); app.UseSwaggerUI(); + app.MapControllers().RequireRateLimiting("api"); } +app.Run(); -app.UseHttpsRedirection(); -app.MapControllers(); - -app.Run(); \ No newline at end of file +public partial class Program; diff --git a/Subscription & Billing/Services/PlanCatalog.cs b/Subscription & Billing/Services/PlanCatalog.cs new file mode 100644 index 0000000..2b5b370 --- /dev/null +++ b/Subscription & Billing/Services/PlanCatalog.cs @@ -0,0 +1,17 @@ +namespace Subscription_Backend.Services; + +// Prices belong to the server, never to the caller. +public static class PlanCatalog +{ + public static decimal Price(string plan) => plan switch + { + "basic" => 990m, + "pro" => 1990m, + _ => throw new BillingException(400, "Unknown plan. Use basic or pro.") + }; +} + +public sealed class BillingException(int statusCode, string message) : Exception(message) +{ + public int StatusCode { get; } = statusCode; +} diff --git a/Subscription & Billing/Services/SubscriptionService.cs b/Subscription & Billing/Services/SubscriptionService.cs index cac39ce..79a2614 100644 --- a/Subscription & Billing/Services/SubscriptionService.cs +++ b/Subscription & Billing/Services/SubscriptionService.cs @@ -1,85 +1,55 @@ -using MassTransit; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using MassTransit; using Microsoft.EntityFrameworkCore; using Subscription_Backend.Dtos; using Subscription_Backend.Messages; using Subscription_Backend.Models; -using System.Text.Json; namespace Subscription_Backend.Services; -public class SubscriptionService +public sealed class SubscriptionService(AppDbContext db, IPublishEndpoint publisher, + ILogger logger) { - private readonly AppDbContext _db; - private readonly IPublishEndpoint _publishEndpoint; - - public SubscriptionService(AppDbContext db, IPublishEndpoint publishEndpoint) - { - _db = db; - _publishEndpoint = publishEndpoint; - } - - public async Task CreateSubscriptionAsync(CreateSubscriptionRequest request, Guid idempotencyKey) + public async Task CreateSubscriptionAsync( + CreateSubscriptionRequest request, Guid key, CancellationToken ct) { - var existing = await _db.IdempotencyRecords - .FirstOrDefaultAsync(x => - x.Key == idempotencyKey && - x.Endpoint == "POST /api/subscriptions/create"); - - if (existing != null) + var amount = PlanCatalog.Price(request.Plan); + var hash = Convert.ToHexString(SHA256.HashData( + Encoding.UTF8.GetBytes($"v1:{request.UserId}:{request.Plan}"))); + await using var tx = await db.Database.BeginTransactionAsync(ct); + // Works across replicas. Hash collisions only serialize unrelated requests. + var lockId = BitConverter.ToInt64(SHA256.HashData(key.ToByteArray()), 0); + await db.Database.ExecuteSqlInterpolatedAsync($"SELECT pg_advisory_xact_lock({lockId})", ct); + var existing = await db.IdempotencyRecords.AsNoTracking().SingleOrDefaultAsync(x => x.Key == key, ct); + if (existing is not null) { - return JsonSerializer.Deserialize(existing.Response)!; + if (existing.RequestHash != hash) + throw new BillingException(409, "Idempotency key was used with a different request."); + return JsonSerializer.Deserialize(existing.Response)!; } - - var strategy = _db.Database.CreateExecutionStrategy(); - - return await strategy.ExecuteAsync(async () => + if (!await db.Users.AnyAsync(x => x.Id == request.UserId, ct)) + throw new BillingException(404, "User not found."); + + var subscription = new Subscription { UserId = request.UserId, Plan = request.Plan }; + db.Subscriptions.Add(subscription); + db.Payments.Add(new Payment { Subscription = subscription, Amount = amount }); + await db.SaveChangesAsync(ct); + var response = new SubscriptionResponse(subscription.Id, subscription.UserId, subscription.Plan, false); + db.IdempotencyRecords.Add(new IdempotencyRecord { - await using var tx = await _db.Database.BeginTransactionAsync(); - - try - { - var user = await _db.Users.FindAsync(request.UserId); - if (user == null) - throw new Exception("User not found"); - - var subscription = new Subscription - { - UserId = user.Id, - Plan = request.Plan, - IsActive = false - }; - - _db.Subscriptions.Add(subscription); - await _db.SaveChangesAsync(); - - _db.Payments.Add(new Payment - { - SubscriptionId = subscription.Id, - Amount = request.Amount - }); - - await _db.SaveChangesAsync(); - - _db.IdempotencyRecords.Add(new IdempotencyRecord - { - Key = idempotencyKey, - Endpoint = "POST /api/subscriptions/create", - Response = JsonSerializer.Serialize(subscription), - StatusCode = 200, - CreatedAt = DateTime.UtcNow - }); - - await _db.SaveChangesAsync(); - - await tx.CommitAsync(); - - return subscription; - } - catch - { - await tx.RollbackAsync(); - throw; - } + Key = key, RequestHash = hash, Response = JsonSerializer.Serialize(response), CreatedAt = DateTime.UtcNow }); + // Scoped publisher is intercepted by the EF bus outbox. + // Payment, subscription, response and event commit or roll back together. + await publisher.Publish(new SubscriptionPaidEvent + { + SubscriptionId = subscription.Id, Amount = amount, PaidAt = DateTime.UtcNow + }, ct); + await db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + logger.LogInformation("Payment recorded for {SubscriptionId}, key {IdempotencyKey}", subscription.Id, key); + return response; } -} \ No newline at end of file +} diff --git a/Subscription & Billing/Subscription & Billing.csproj b/Subscription & Billing/Subscription & Billing.csproj index 67b51b0..dee8eec 100644 --- a/Subscription & Billing/Subscription & Billing.csproj +++ b/Subscription & Billing/Subscription & Billing.csproj @@ -1,20 +1,18 @@ - net8.0 enable enable - Subscription___Billing + Subscription_Backend + Billing - - - - - - - - + + + + + + + - diff --git a/Subscription & Billing/Subscription & Billing.http b/Subscription & Billing/Subscription & Billing.http index 552ddc5..16e65df 100644 --- a/Subscription & Billing/Subscription & Billing.http +++ b/Subscription & Billing/Subscription & Billing.http @@ -1,6 +1,20 @@ -@Subscription___Billing_HostAddress = http://localhost:5265 +@host = http://localhost:8080 -GET {{Subscription___Billing_HostAddress}}/weatherforecast/ -Accept: application/json +### Create a user; substitute the returned id below +POST {{host}}/api/users +Content-Type: application/json -### +{"name":"Stepan","email":"stepan@example.com"} + +### Create subscription; retry with the same key and body +POST {{host}}/api/subscriptions/create +Content-Type: application/json +Idempotency-Key: 75be42df-1323-48c2-a317-55bb64445a1b + +{"userId":1,"plan":"basic"} + +### Current state +GET {{host}}/api/subscriptions/1 + +### Readiness +GET {{host}}/health/ready diff --git a/Subscription & Billing/Subscription_Billing.md b/Subscription & Billing/Subscription_Billing.md deleted file mode 100644 index 3ed6b99..0000000 --- a/Subscription & Billing/Subscription_Billing.md +++ /dev/null @@ -1,561 +0,0 @@ -# Subscription & Billing Backend - -> Снимок проекта на момент **недели 3** (API + транзакции + готовность к асинхронности). - ---- - -## Properties/launchSettings.json -```json -{ - "$schema": "http://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:4721", - "sslPort": 44338 - } - }, - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "swagger", - "applicationUrl": "http://localhost:5265", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "swagger", - "applicationUrl": "https://localhost:7289;http://localhost:5265", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "launchUrl": "swagger", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} -``` - ---- - -## Controllers/SubscriptionBillingController.cs -```csharp -using Microsoft.AspNetCore.Mvc; -using Subscription_Backend.Dtos; -using Subscription_Backend.Models; -using Subscription_Backend.Services; - -namespace Subscription_Backend.Controllers; - -[ApiController] -[Route("api/subscriptions")] -public class SubscriptionWorkflowController : ControllerBase -{ - private readonly SubscriptionService _service; - - public SubscriptionWorkflowController(SubscriptionService service) - { - _service = service; - } - - [HttpPost("create")] - public async Task> Create(CreateSubscriptionRequest request) - { - var subscription = await _service.CreateSubscriptionAsync(request); - return Ok(subscription); - } -} -``` - ---- - -## Controllers/WeatherForecastController.cs -```csharp -using Microsoft.AspNetCore.Mvc; - -namespace Subscription___Billing.Controllers -{ - [ApiController] - [Route("[controller]")] - public class WeatherForecastController : ControllerBase - { - private static readonly string[] Summaries = new[] - { - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" - }; - - private readonly ILogger _logger; - - public WeatherForecastController(ILogger logger) - { - _logger = logger; - } - - [HttpGet(Name = "GetWeatherForecast")] - public IEnumerable Get() - { - return Enumerable.Range(1, 5).Select(index => new WeatherForecast - { - Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - TemperatureC = Random.Shared.Next(-20, 55), - Summary = Summaries[Random.Shared.Next(Summaries.Length)] - }) - .ToArray(); - } - } -} -``` - ---- - -## Dtos/CreateSubscriptionRequest.cs -```csharp -namespace Subscription_Backend.Dtos; - -public class CreateSubscriptionRequest -{ - public int UserId { get; set; } - public string Plan { get; set; } = null!; - public decimal Amount { get; set; } -} -``` - ---- - -## Models/Payment.cs -```csharp -namespace Subscription_Backend.Models; - -public class Payment -{ - public int Id { get; set; } - - public int SubscriptionId { get; set; } - public Subscription Subscription { get; set; } = null!; - - public decimal Amount { get; set; } - public DateTime PaidAt { get; set; } = DateTime.UtcNow; -} -``` - ---- - -## Models/Subscription.cs -```csharp -namespace Subscription_Backend.Models -{ - public class Subscription - { - public int Id { get; set; } - public int UserId { get; set; } - public string Plan { get; set; } = null!; - public bool IsActive { get; set; } - } -} -``` - ---- - -## Models/User.cs -```csharp -namespace Subscription_Backend.Models -{ - public class User - { - public int Id { get; set; } - public string Name { get; set; } = null!; - public string Email { get; set; } = null!; - } -} -``` - ---- - -## Services/SubscriptionService.cs -```csharp -using MassTransit; -using Microsoft.EntityFrameworkCore; -using Subscription_Backend.Dtos; -using Subscription_Backend.Messages; -using Subscription_Backend.Models; - -namespace Subscription_Backend.Services; - -public class SubscriptionService -{ - private readonly AppDbContext _db; - private readonly IPublishEndpoint _publishEndpoint; - - public SubscriptionService(AppDbContext db, IPublishEndpoint publishEndpoint) - { - _db = db; - _publishEndpoint = publishEndpoint; - } - - public async Task CreateSubscriptionAsync(CreateSubscriptionRequest request) - { - await using var transaction = await _db.Database.BeginTransactionAsync(); - - try - { - var user = await _db.Users.FindAsync(request.UserId); - if (user == null) - throw new Exception("User not found"); - - var subscription = new Subscription - { - UserId = user.Id, - Plan = request.Plan, - IsActive = false - }; - - _db.Subscriptions.Add(subscription); - await _db.SaveChangesAsync(); - - var payment = new Payment - { - SubscriptionId = subscription.Id, - Amount = request.Amount - }; - - _db.Payments.Add(payment); - await _db.SaveChangesAsync(); - - await transaction.CommitAsync(); - - await _publishEndpoint.Publish(new SubscriptionPaidEvent - { - SubscriptionId = subscription.Id, - Amount = request.Amount, - PaidAt = DateTime.UtcNow - }); - - return subscription; - } - catch - { - await transaction.RollbackAsync(); - throw; - } - } -} -``` -csharp -using Microsoft.EntityFrameworkCore; -using Subscription_Backend.Dtos; -using Subscription_Backend.Models; - -namespace Subscription_Backend.Services; - -public class SubscriptionService -{ - private readonly AppDbContext _db; - - public SubscriptionService(AppDbContext db) - { - _db = db; - } - - public async Task CreateSubscriptionAsync(CreateSubscriptionRequest request) - { - await using var transaction = await _db.Database.BeginTransactionAsync(); - - try - { - var user = await _db.Users.FindAsync(request.UserId); - if (user == null) - throw new Exception("User not found"); - - var subscription = new Subscription - { - UserId = user.Id, - Plan = request.Plan, - IsActive = false - }; - - _db.Subscriptions.Add(subscription); - await _db.SaveChangesAsync(); - - var payment = new Payment - { - SubscriptionId = subscription.Id, - Amount = request.Amount - }; - - _db.Payments.Add(payment); - await _db.SaveChangesAsync(); - - subscription.IsActive = true; - await _db.SaveChangesAsync(); - - await transaction.CommitAsync(); - - return subscription; - } - catch - { - await transaction.RollbackAsync(); - throw; - } - } -} -``` - ---- - -## AppDbContext.cs -```csharp -using Microsoft.EntityFrameworkCore; -using Subscription_Backend.Models; -using System.Collections.Generic; - -namespace Subscription_Backend -{ - public class AppDbContext : DbContext - { - public AppDbContext(DbContextOptions options) : base(options) { } - - public DbSet Users => Set(); - public DbSet Subscriptions => Set(); - public DbSet Payments => Set(); - } -} -``` - ---- - -## appsettings.json -```json -{ - "ConnectionStrings": { - "DefaultConnection": "Host=localhost;Database=subscription_db;Username=postgres;Password=postgres" - } -} -```json -{ - "ConnectionStrings": { - "DefaultConnection": "Host=localhost;Database=subscription_db;Username=postgres;Password=postgres" - } -} -``` - ---- - -## Consumers/SubscriptionActivationConsumer.cs -```csharp -using MassTransit; -using Subscription_Backend.Messages; -using Subscription_Backend.Models; - -namespace Subscription_Backend.Consumers; - -public class SubscriptionActivationConsumer : IConsumer -{ - private readonly AppDbContext _db; - private readonly ILogger _logger; - - public SubscriptionActivationConsumer(AppDbContext db, ILogger logger) - { - _db = db; - _logger = logger; - } - - public async Task Consume(ConsumeContext context) - { - var message = context.Message; - _logger.LogInformation($"[Worker] Получено событие оплаты для подписки {message.SubscriptionId}"); - - // Имитация долгой обработки - await Task.Delay(5000); - - var subscription = await _db.Subscriptions.FindAsync(message.SubscriptionId); - - if (subscription == null) - { - _logger.LogError($"Подписка {message.SubscriptionId} не найдена!"); - return; - } - - // Активируем подписку - subscription.IsActive = true; - await _db.SaveChangesAsync(); - - _logger.LogInformation($"[Worker] Подписка {message.SubscriptionId} успешно активирована!"); - } -} -``` - ---- - -## Messages/SubscriptionPaidEvent.cs -```csharp -namespace Subscription_Backend.Messages; - -public record SubscriptionPaidEvent -{ - public int SubscriptionId { get; init; } - public decimal Amount { get; init; } - public DateTime PaidAt { get; init; } -} -``` - ---- - -## Program.cs -```csharp -using MassTransit; -using Microsoft.EntityFrameworkCore; -using Subscription_Backend; -using Subscription_Backend.Consumers; -using Subscription_Backend.Services; - -var builder = WebApplication.CreateBuilder(args); - -builder.Services.AddControllers(); - -builder.Services.AddDbContext(options => - options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); - -builder.Services.AddScoped(); - -builder.Services.AddMassTransit(x => -{ - x.AddConsumer(); - - x.UsingRabbitMq((context, cfg) => - { - cfg.Host("localhost", "/", h => - { - h.Username("guest"); - h.Password("guest"); - }); - - cfg.ConfigureEndpoints(context); - }); -}); - -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); - -var app = builder.Build(); - -using (var scope = app.Services.CreateScope()) -{ - var db = scope.ServiceProvider.GetRequiredService(); - db.Database.EnsureCreated(); -} - -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); -} - -app.UseHttpsRedirection(); -app.MapControllers(); - -app.Run(); -``` -csharp -using Microsoft.EntityFrameworkCore; -using Subscription_Backend; -using Subscription_Backend.Services; - -var builder = WebApplication.CreateBuilder(args); - -builder.Services.AddControllers(); - -// DbContext -builder.Services.AddDbContext(options => - options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); - -builder.Services.AddScoped(); - -// Swagger -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); - -var app = builder.Build(); - -// Swagger UI -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); -} - -app.UseHttpsRedirection(); - -app.MapControllers(); - -app.Run(); -``` - ---- - -## Subscription & Billing.http -```http -@Subscription___Billing_HostAddress = http://localhost:5265 - -GET {{Subscription___Billing_HostAddress}}/weatherforecast/ -Accept: application/json -``` - ---- - -## WeatherForecast.cs -```csharp -namespace Subscription___Billing -{ - public class WeatherForecast - { - public DateOnly Date { get; set; } - - public int TemperatureC { get; set; } - - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); - - public string? Summary { get; set; } - } -} -``` - ---- - -## NuGet packages - -- MassTransit -- MassTransit.RabbitMQ -- Microsoft.EntityFrameworkCore -- Npgsql.EntityFrameworkCore.PostgreSQL -- Swashbuckle.AspNetCore - ---- - -## Infrastructure requirements - -- PostgreSQL (localhost) -- RabbitMQ (localhost, guest / guest) - ---- - -### Статус -- POST `/api/subscriptions/create` работает через Swagger -- Подписка создаётся **неактивной** -- После оплаты публикуется `SubscriptionPaidEvent` -- `SubscriptionActivationConsumer` асинхронно активирует подписку -- Архитектура соответствует **неделе 3 (async + message broker)** - diff --git a/Subscription & Billing/WeatherForecast.cs b/Subscription & Billing/WeatherForecast.cs deleted file mode 100644 index 072ba07..0000000 --- a/Subscription & Billing/WeatherForecast.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Subscription___Billing -{ - public class WeatherForecast - { - public DateOnly Date { get; set; } - - public int TemperatureC { get; set; } - - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); - - public string? Summary { get; set; } - } -} diff --git a/Subscription & Billing/appsettings.json b/Subscription & Billing/appsettings.json index 333ee92..24a5110 100644 --- a/Subscription & Billing/appsettings.json +++ b/Subscription & Billing/appsettings.json @@ -1,5 +1,5 @@ { - "ConnectionStrings": { - "DefaultConnection": "Host=localhost;Database=subscription_db;Username=postgres;Password=1410197140qzps" - } -} \ No newline at end of file + "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning", "Microsoft.EntityFrameworkCore": "Warning" } }, + "AllowedHosts": "*", + "Api": { "ConcurrencyLimit": 64 } +} diff --git a/SubscriptionBackend/.gitignore b/SubscriptionBackend/.gitignore deleted file mode 100644 index e69de29..0000000 diff --git a/SubscriptionBackend/Program.cs b/SubscriptionBackend/Program.cs deleted file mode 100644 index 00ff539..0000000 --- a/SubscriptionBackend/Program.cs +++ /dev/null @@ -1,44 +0,0 @@ -var builder = WebApplication.CreateBuilder(args); - -// Add services to the container. -// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); - -var app = builder.Build(); - -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); -} - -app.UseHttpsRedirection(); - -var summaries = new[] -{ - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" -}; - -app.MapGet("/weatherforecast", () => -{ - var forecast = Enumerable.Range(1, 5).Select(index => - new WeatherForecast - ( - DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - Random.Shared.Next(-20, 55), - summaries[Random.Shared.Next(summaries.Length)] - )) - .ToArray(); - return forecast; -}) -.WithName("GetWeatherForecast") -.WithOpenApi(); - -app.Run(); - -record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) -{ - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); -} diff --git a/SubscriptionBackend/Properties/launchSettings.json b/SubscriptionBackend/Properties/launchSettings.json deleted file mode 100644 index 43a2352..0000000 --- a/SubscriptionBackend/Properties/launchSettings.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "$schema": "http://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:35828", - "sslPort": 44331 - } - }, - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "swagger", - "applicationUrl": "http://localhost:5182", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "swagger", - "applicationUrl": "https://localhost:7267;http://localhost:5182", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "launchUrl": "swagger", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/SubscriptionBackend/SubscriptionBackend.csproj b/SubscriptionBackend/SubscriptionBackend.csproj deleted file mode 100644 index e5d16a7..0000000 --- a/SubscriptionBackend/SubscriptionBackend.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - net8.0 - enable - enable - - - - - - - - diff --git a/SubscriptionBackend/SubscriptionBackend.http b/SubscriptionBackend/SubscriptionBackend.http deleted file mode 100644 index 3dd8def..0000000 --- a/SubscriptionBackend/SubscriptionBackend.http +++ /dev/null @@ -1,6 +0,0 @@ -@SubscriptionBackend_HostAddress = http://localhost:5182 - -GET {{SubscriptionBackend_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/SubscriptionBackend/appsettings.Development.json b/SubscriptionBackend/appsettings.Development.json deleted file mode 100644 index 0c208ae..0000000 --- a/SubscriptionBackend/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/SubscriptionBackend/appsettings.json b/SubscriptionBackend/appsettings.json deleted file mode 100644 index 10f68b8..0000000 --- a/SubscriptionBackend/appsettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..5c6b293 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,75 @@ +name: subscription-billing +x-runtime: &runtime + build: . + environment: &environment + ConnectionStrings__DefaultConnection: Host=postgres;Database=billing;Username=billing;Password=${POSTGRES_PASSWORD:-local-billing-only} + RabbitMq__Host: rabbitmq + RabbitMq__Username: billing + RabbitMq__Password: ${RABBITMQ_PASSWORD:-local-broker-only} + ASPNETCORE_HTTP_PORTS: '8080' + restart: unless-stopped + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: billing + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-local-billing-only} + POSTGRES_DB: billing + volumes: [postgres-data:/var/lib/postgresql/data] + healthcheck: + test: [CMD-SHELL, 'pg_isready -U billing -d billing'] + interval: 3s + timeout: 3s + retries: 30 + rabbitmq: + image: rabbitmq:4-management-alpine + environment: + RABBITMQ_DEFAULT_USER: billing + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-local-broker-only} + volumes: [rabbitmq-data:/var/lib/rabbitmq] + ports: ['127.0.0.1:15672:15672'] + healthcheck: + test: [CMD, rabbitmq-diagnostics, -q, ping] + interval: 5s + timeout: 5s + retries: 30 + migrate: + <<: *runtime + environment: + <<: *environment + ServiceRole: Migrate + restart: 'no' + depends_on: + postgres: {condition: service_healthy} + api: + <<: *runtime + environment: + <<: *environment + ServiceRole: Api + ports: ['127.0.0.1:8080:8080'] + depends_on: + migrate: {condition: service_completed_successfully} + rabbitmq: {condition: service_healthy} + worker: + <<: *runtime + environment: + <<: *environment + ServiceRole: Worker + depends_on: + migrate: {condition: service_completed_successfully} + rabbitmq: {condition: service_healthy} + tests: + profiles: [test] + build: {context: ., target: test} + environment: + TEST_DATABASE: Host=postgres;Database=billing_tests;Username=billing;Password=${POSTGRES_PASSWORD:-local-billing-only} + TEST_RABBIT_HOST: rabbitmq + TEST_RABBIT_PASSWORD: ${RABBITMQ_PASSWORD:-local-broker-only} + depends_on: + postgres: {condition: service_healthy} + rabbitmq: {condition: service_healthy} + +volumes: + postgres-data: + rabbitmq-data: diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..0e56d67 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,111 @@ +# Архитектурные решения + +## Границы и зависимости + +Один небольшой кодовый модуль, два процесса с разными ролями. Контроллеры отвечают +за HTTP и DTO, SubscriptionService за бизнес-транзакцию, EF Core за хранение, +consumer за асинхронную активацию. API и worker используют одну схему БД. +Это осознанный компромисс: нет распределённой транзакции и лишних сетевых вызовов, +но процессы связаны схемой и требуют совместимых изменений при развёртывании. + +`ServiceRole=Api|Worker|All|Migrate` определяет роль. Compose использует первые +два для работы и отдельный одноразовый Migrate. Автоматических миграций на каждом +API-экземпляре нет. Generic repository поверх EF Core не добавлен: он скрыл бы +транзакцию и запросы без полезной новой границы. + +Для новой миграции из корня: `dotnet tool restore`, затем +`dotnet ef migrations add Name --project "Subscription & Billing"`. +Изменение схемы проверяется на новой БД и оформляется совместимо с rolling deployment. + +## Денежная транзакция + +```mermaid +sequenceDiagram + participant C as Client + participant A as API + participant P as PostgreSQL + participant O as Outbox delivery + participant R as RabbitMQ + participant W as Worker + C->>A: POST + UUID key + A->>P: BEGIN + pg_advisory_xact_lock(key hash) + A->>P: Look up key and request hash + alt replay + P-->>A: Saved response + A-->>C: 202 with original body + else new request + A->>P: Subscription + payment + key + outbox event + A->>P: COMMIT + A-->>C: 202 + Location + O->>P: Read pending outbox + O->>R: Publish with broker confirmation + R->>W: SubscriptionPaidEvent + W->>P: Inbox + conditional activation transaction + W-->>R: ACK after success + end +``` + +Цена берётся из каталога сервера. PostgreSQL хранит decimal(18,2), проверяет +положительную сумму и уникальность payment.subscriptionId. User FK защищает +ссылочную целостность. Ответ и отпечаток запроса сохраняются вместе с оплатой. +Advisory lock живёт до конца транзакции, одинаково работает на разных API-репликах. +Коллизия 64-битного хеша замедлит два разных ключа, но не смешает их данные. + +Ключи не удаляются автоматически: в демонстрации гарантия повторов не имеет TTL. +В production retention нужно согласовать с периодом клиентских повторов и аудитом. +Ключ сейчас глобальный; после добавления auth его область должна включать tenant +и операцию. Чтение GET возвращает текущий статус, POST replay возвращает сохранённый. +Это позволяет безопасно повторить запрос после неизвестного результата COMMIT. + +## Отказы + +| Сбой | Результат | +|---|---| +| Ошибка до COMMIT | Вся транзакция откатывается, тот же ключ можно повторить | +| Ответ потерян после COMMIT | Повтор возвращает сохранённый ответ и ID | +| Брокер недоступен | Событие остаётся в outbox, активация отложена | +| Процесс API остановлен | Pending outbox остаётся в PostgreSQL | +| Worker остановлен | Durable queue накапливает события | +| Worker умер после UPDATE до ACK | Повторная доставка безопасна благодаря inbox и условному UPDATE | +| Несогласованное событие | Три retry, затем error queue; требуется расследование и redrive | + +Outbox не обещает exactly-once транспорт. Inbox отсеивает повторные message IDs, +а условный UPDATE защищает от того же бизнес-события с новым message ID. +Нельзя автоматически расширять это утверждение на внешние побочные эффекты, +например выдачу лицензии сторонним HTTP-сервисом: ему тоже нужна идемпотентность. + +Используется [EF transactional outbox MassTransit](https://masstransit.io/documentation/configuration/middleware/outbox) +и [transaction-scoped advisory lock PostgreSQL](https://www.postgresql.org/docs/16/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS). +Версия MassTransit 8.3.6 выбрана с зависимостями EF Core 8. + +## Масштабирование и наблюдение + +- `docker compose up -d --scale worker=3` добавляет competing consumers одной очереди. + Это повышает параллелизм, но требует контроля соединений БД и нагрузки на PostgreSQL. +- API не хранит сессии или ключи в памяти. Несколько API за балансировщиком + используют общую БД; текущий Compose публикует фиксированный порт одного API. +- Concurrency limiter даёт backpressure и `429`, но не является общей квотой + пользователя или распределённым rate limiter. Клиент повторяет с backoff и тем же ключом. +- Список читает только DTO, без tracking, использует keyset вместо OFFSET. + Кеш не добавлен: stale состояние активации усложнило бы денежный сценарий. +- Очереди и outbox конечны по ресурсам. Нужны алерты на возраст/число outbox, + глубину очереди, error queue, latency, 429/5xx, CPU и соединения БД. + RabbitMQ UI доступен на localhost:15672; JSON-логи показывают ID подписки и ключ. +- `/health/live` отвечает за процесс; `/health/ready` проверяет БД и MassTransit. + Во время outage readiness падает, хотя API всё ещё может принять запись в outbox. + Балансировщику production нужно выбрать политику доступности при degraded broker. + +Нагрузочную ёмкость измеряют отдельно на оговорённом железе. Для теста записи нужны +разные ключи и пользователи, иначе будет измеряться replay или сериализация одного +ключа. Настройки 64/32/8 являются исходными ограничениями, а не доказанной ёмкостью. + +## Проверяемость + +Интеграционные тесты стартуют HTTP host через WebApplicationFactory, применяют +миграции к отдельной PostgreSQL БД и используют отдельную топологию RabbitMQ. +Инъекция ошибки во второй SaveChanges проверяет откат уже вставленной оплаты. +Smoke-скрипт проверяет отказ брокера и перезапуск процесса на реальных контейнерах. +Тесты не используют EF InMemory и не подменяют брокер заглушкой. + +Старый пароль удалён из конфигурации; Git history сохраняет прошлые версии. +Если он использовался где-либо вне локального примера, его необходимо сменить. diff --git a/docs/verification.md b/docs/verification.md new file mode 100644 index 0000000..2b40c06 --- /dev/null +++ b/docs/verification.md @@ -0,0 +1,47 @@ +# Проверка от 29 сентября 2026 + +## Функциональность + +- `dotnet build`: 0 ошибок, 0 предупреждений. +- Docker Compose: запуск API, worker, PostgreSQL, RabbitMQ; мигратор завершился с кодом 0. +- 17 xUnit tests: все прошли на настоящих PostgreSQL и RabbitMQ за 5.43 секунды. +- Принудительная ошибка после INSERT платежа: подписка, платёж и outbox откатились; + тот же ключ затем успешно принят. +- 20 одновременных повторов: одна оплата, одинаковые тела ответа. +- Перегрузка: 24 занятых обработчика в тестовом хосте, следующий запрос получает 429, + `/health/live` по-прежнему отвечает 200. Production-default лимит API равен 64. +- `scripts/verify.ps1`: выключение брокера, приём оплаты в outbox, перезапуск API, + replay исходного ответа и активация после восстановления брокера. Проверка прошла. + +## Локальный мини-бенчмарк + +```sh +python scripts/load.py --requests 2000 --concurrency 40 +``` + +Один API и один worker. Docker Desktop Linux: 12 vCPU, около 7.63 GiB RAM; +клиент Python 3.13 на Windows 11. Runtime .NET 8.0.31, PostgreSQL 16 и RabbitMQ 4. +Каждый POST использовал новый UUID-ключ, один общий пользователь и тариф basic. +Клиент подключался к `127.0.0.1:8080`, создавал новое HTTP-соединение на запрос. +API уже работал после smoke-проверки; отдельного формального warmup не было. + +| Метрика | Результат | +|---|---:| +| Запросы / параллелизм | 2000 / 40 | +| HTTP 202 | 2000 | +| Время записи | 4.839 с | +| Принятые запросы в секунду | 413.27 | +| p50 / p95 HTTP | 90.69 / 152.23 мс | +| Активированные подписки после записи | 2000 | + +Это один короткий локальный прогон. RPS измеряет принятие в БД/outbox, а не +завершение активации; после записи скрипт отдельно дождался всех активаций. +Он включает задержки клиента, сети и БД. Эти числа не являются production SLO, +долгим soak test или максимальной ёмкостью сервиса. + +При первом запуске клиент использовал `localhost`: задержки подключения +на этой Windows-системе искажали результат. Прямой IPv4 убрал эту задержку; +скрипт использует его по умолчанию. На другой машине результаты будут отличаться. + +Тесты и smoke выполняются также в `.github/workflows/ci.yml` для push и pull request. +Локальный результат и статус GitHub Actions нужно рассматривать отдельно. diff --git a/global.json b/global.json new file mode 100644 index 0000000..ae9f38c --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "8.0.400", + "rollForward": "latestFeature" + } +} diff --git a/scripts/load.py b/scripts/load.py new file mode 100644 index 0000000..14a6ba5 --- /dev/null +++ b/scripts/load.py @@ -0,0 +1,81 @@ +"""Reproducible write benchmark using only Python 3's standard library.""" +import argparse +import concurrent.futures +import json +import math +import platform +import statistics +import time +import urllib.error +import urllib.request +import uuid + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--url', default='http://127.0.0.1:8080') +parser.add_argument('--requests', type=int, default=200) +parser.add_argument('--concurrency', type=int, default=20) +args = parser.parse_args() +if args.requests < 1 or args.concurrency < 1: + parser.error('requests and concurrency must be positive') + +def request(path, body=None, key=None): + headers = {'Content-Type': 'application/json'} + if key: + headers['Idempotency-Key'] = key + req = urllib.request.Request(args.url + path, + data=json.dumps(body).encode() if body is not None else None, headers=headers) + try: + with urllib.request.urlopen(req, timeout=30) as response: + return response.status, json.load(response) + except urllib.error.HTTPError as error: + return error.code, None + +status, user = request('/api/users', {'name': 'Load test', 'email': f'load-{uuid.uuid4()}@example.com'}) +if status != 201: + raise SystemExit(f'Cannot create user: HTTP {status}') + +def write(_): + start = time.perf_counter() + status, result = request('/api/subscriptions/create', + {'userId': user['id'], 'plan': 'basic'}, str(uuid.uuid4())) + return status, (time.perf_counter() - start) * 1000, result + +start = time.perf_counter() +with concurrent.futures.ThreadPoolExecutor(max_workers=args.concurrency) as pool: + results = list(pool.map(write, range(args.requests))) +elapsed = time.perf_counter() - start +latencies = sorted(result[1] for result in results) +accepted = [result[2]['id'] for result in results if result[0] == 202] +codes = {str(code): sum(r[0] == code for r in results) for code in sorted({r[0] for r in results})} + +# Poll the bounded list instead of issuing a GET per write. +deadline = time.monotonic() + 90 +activated = set() +while time.monotonic() < deadline and len(activated) < len(accepted): + after = 0 + current = set() + while True: + status, page = request(f'/api/subscriptions?userId={user["id"]}&afterId={after}&limit=100') + if status != 200: + raise SystemExit(f'Polling failed: HTTP {status}') + current.update(item['id'] for item in page if item['isActive']) + if len(page) < 100: + break + after = page[-1]['id'] + activated = current + if len(activated) < len(accepted): + time.sleep(0.3) + +summary = { + 'platform': platform.platform(), 'requests': args.requests, + 'concurrency': args.concurrency, 'elapsed_seconds': round(elapsed, 3), + 'accepted_per_second': round(len(accepted) / elapsed, 2), + 'http_codes': codes, + 'p50_ms': round(statistics.median(latencies), 2), + 'p95_ms': round(latencies[math.ceil(len(latencies) * .95) - 1], 2), + 'accepted': len(accepted), 'activated': len(activated), + 'all_accepted_activated': set(accepted) <= activated, +} +print(json.dumps(summary, indent=2)) +if len(accepted) != args.requests or not summary['all_accepted_activated']: + raise SystemExit(1) diff --git a/scripts/verify.ps1 b/scripts/verify.ps1 new file mode 100644 index 0000000..33d0883 --- /dev/null +++ b/scripts/verify.ps1 @@ -0,0 +1,61 @@ +param([string]$BaseUrl = 'http://localhost:8080') +$ErrorActionPreference = 'Stop' +function Wait-Ready { + $deadline = (Get-Date).AddSeconds(90) + while ((Get-Date) -lt $deadline) { + try { + $response = Invoke-WebRequest "$BaseUrl/health/ready" -TimeoutSec 3 + if ($response.StatusCode -eq 200) { return } + } catch { } + Start-Sleep -Milliseconds 500 + } + throw 'API did not become ready.' +} +function Wait-Active([int]$Id) { + $deadline = (Get-Date).AddSeconds(90) + while ((Get-Date) -lt $deadline) { + $item = Invoke-RestMethod "$BaseUrl/api/subscriptions/$Id" + if ($item.isActive) { return } + Start-Sleep -Milliseconds 300 + } + throw "Subscription $Id was not activated." +} +function Docker-Step([string[]]$Arguments) { + & docker @Arguments + if ($LASTEXITCODE -ne 0) { throw "Docker failed: $Arguments" } +} +Wait-Ready +$user = Invoke-RestMethod "$BaseUrl/api/users" -Method Post -ContentType 'application/json' -Body (@{ + name = 'Smoke'; email = "smoke-$([guid]::NewGuid().ToString('N'))@example.com" +} | ConvertTo-Json) +$body = @{ userId = $user.id; plan = 'basic' } | ConvertTo-Json +$headers = @{ 'Idempotency-Key' = [guid]::NewGuid().ToString() } +$first = Invoke-WebRequest "$BaseUrl/api/subscriptions/create" -Method Post -ContentType 'application/json' -Headers $headers -Body $body +if ($first.StatusCode -ne 202) { throw 'Expected 202.' } +$subscription = $first.Content | ConvertFrom-Json +Wait-Active $subscription.id +$repeat = Invoke-WebRequest "$BaseUrl/api/subscriptions/create" -Method Post -ContentType 'application/json' -Headers $headers -Body $body +if ($repeat.Content -ne $first.Content) { throw 'Replay response changed.' } + +# A live outage verifies durable storage, process restart and eventual delivery. +try { + Docker-Step @('compose', 'stop', 'rabbitmq') + $headers['Idempotency-Key'] = [guid]::NewGuid().ToString() + $pending = Invoke-RestMethod "$BaseUrl/api/subscriptions/create" -Method Post -ContentType 'application/json' -Headers $headers -Body $body -TimeoutSec 15 + if ($pending.isActive) { throw 'Subscription unexpectedly activated with broker stopped.' } + Docker-Step @('compose', 'restart', 'api') + $deadline = (Get-Date).AddSeconds(45) + $restarted = $false + while ((Get-Date) -lt $deadline) { + try { + $replay = Invoke-RestMethod "$BaseUrl/api/subscriptions/create" -Method Post -ContentType 'application/json' -Headers $headers -Body $body -TimeoutSec 3 + if ($replay.id -ne $pending.id) { throw 'Replay after process restart returned another ID.' } + $restarted = $true + break + } catch { Start-Sleep -Milliseconds 300 } + } + if (-not $restarted) { throw 'API did not accept replay while broker was unavailable.' } +} finally { Docker-Step @('compose', 'start', 'rabbitmq') } +Wait-Ready +Wait-Active $pending.id +Write-Output 'VERIFIED: HTTP workflow, exact replay, broker outage, API restart, outbox recovery.' diff --git a/tests/Billing.Tests/Billing.Tests.csproj b/tests/Billing.Tests/Billing.Tests.csproj new file mode 100644 index 0000000..a553681 --- /dev/null +++ b/tests/Billing.Tests/Billing.Tests.csproj @@ -0,0 +1,16 @@ + + + net8.0 + enable + enable + false + true + + + + + + + + + diff --git a/tests/Billing.Tests/BillingFixture.cs b/tests/Billing.Tests/BillingFixture.cs new file mode 100644 index 0000000..be53bde --- /dev/null +++ b/tests/Billing.Tests/BillingFixture.cs @@ -0,0 +1,89 @@ +using Microsoft.AspNetCore.Hosting; +using MassTransit.EntityFrameworkCoreIntegration; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Subscription_Backend; +using Subscription_Backend.Models; +using Xunit; + +namespace Billing.Tests; + +public sealed class FailBeforeCommit : SaveChangesInterceptor +{ + public bool Enabled { get; set; } + public Guid FailedMessageId { get; private set; } + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, InterceptionResult result, CancellationToken ct = default) + { + if (Enabled && eventData.Context!.ChangeTracker.Entries() + .Any(x => x.State == EntityState.Added)) + { + FailedMessageId = eventData.Context.ChangeTracker.Entries().Single().Entity.MessageId; + throw new InvalidOperationException("Injected failure before outbox/response commit."); + } + return ValueTask.FromResult(result); + } +} + +public sealed class BillingFixture : WebApplicationFactory, IAsyncLifetime +{ + public FailBeforeCommit Fault { get; } = new(); + public HttpClient Client { get; private set; } = null!; + private string connection = null!; + + public async Task InitializeAsync() + { + connection = Environment.GetEnvironmentVariable("TEST_DATABASE") + ?? throw new InvalidOperationException("Run tests with docker compose --profile test run --build --rm tests."); + // A separate database and message exchange keep demo data isolated. + if (new Npgsql.NpgsqlConnectionStringBuilder(connection).Database != "billing_tests") + throw new InvalidOperationException("TEST_DATABASE must target the disposable billing_tests database."); + Environment.SetEnvironmentVariable("ConnectionStrings__DefaultConnection", connection); + Environment.SetEnvironmentVariable("RabbitMq__Host", Environment.GetEnvironmentVariable("TEST_RABBIT_HOST") ?? "localhost"); + Environment.SetEnvironmentVariable("RabbitMq__Password", Environment.GetEnvironmentVariable("TEST_RABBIT_PASSWORD") ?? "local-broker-only"); + Environment.SetEnvironmentVariable("RabbitMq__ActivationQueue", "billing-tests-activation"); + Environment.SetEnvironmentVariable("ServiceRole", "All"); + Environment.SetEnvironmentVariable("Api__ConcurrencyLimit", "24"); + var options = new DbContextOptionsBuilder().UseNpgsql(connection).Options; + await using (var db = new AppDbContext(options)) + { + await db.Database.EnsureDeletedAsync(); + await db.Database.MigrateAsync(); + } + Client = CreateClient(); + var deadline = DateTime.UtcNow.AddSeconds(30); + while (DateTime.UtcNow < deadline) + { + var result = await Client.GetAsync("/health/ready"); + if (result.IsSuccessStatusCode) return; + await Task.Delay(200); + } + throw new TimeoutException("Broker/database did not become ready."); + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + builder.ConfigureTestServices(services => + { + services.RemoveAll>(); + services.AddDbContext(o => o.UseNpgsql(connection).AddInterceptors(Fault)); + }); + } + + async Task IAsyncLifetime.DisposeAsync() + { + Client.Dispose(); + await DisposeAsync(); + } + + public async Task WithDb(Func> action) + { + await using var scope = Services.CreateAsyncScope(); + return await action(scope.ServiceProvider.GetRequiredService()); + } +} diff --git a/tests/Billing.Tests/BillingTests.cs b/tests/Billing.Tests/BillingTests.cs new file mode 100644 index 0000000..c101614 --- /dev/null +++ b/tests/Billing.Tests/BillingTests.cs @@ -0,0 +1,217 @@ +using System.Net; +using System.Net.Http.Json; +using System.Security.Cryptography; +using MassTransit; +using MassTransit.EntityFrameworkCoreIntegration; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Subscription_Backend.Dtos; +using Subscription_Backend.Messages; +using Subscription_Backend.Services; +using Xunit; + +namespace Billing.Tests; + +public sealed class BillingTests(BillingFixture fixture) : IClassFixture +{ + private HttpClient Client => fixture.Client; + + private async Task User() + { + var response = await Client.PostAsJsonAsync("/api/users", new { name = "Test", email = $"{Guid.NewGuid():N}@example.com" }); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + return (await response.Content.ReadFromJsonAsync())!; + } + + private Task Create(int userId, Guid key, string plan = "basic") + { + var request = new HttpRequestMessage(HttpMethod.Post, "/api/subscriptions/create") + { Content = JsonContent.Create(new { userId, plan }) }; + request.Headers.Add("Idempotency-Key", key.ToString()); + return Client.SendAsync(request); + } + + private async Task WaitActive(int id) + { + var deadline = DateTime.UtcNow.AddSeconds(20); + while (DateTime.UtcNow < deadline) + { + var result = await Client.GetFromJsonAsync($"/api/subscriptions/{id}"); + if (result!.IsActive) return; + await Task.Delay(100); + } + Assert.Fail($"Subscription {id} was not activated."); + } + + [Fact] + public async Task Payment_is_persisted_and_activated_through_real_broker() + { + var user = await User(); + var response = await Create(user.Id, Guid.NewGuid(), "pro"); + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + var subscription = (await response.Content.ReadFromJsonAsync())!; + Assert.NotNull(response.Headers.Location); + Assert.False(subscription.IsActive); + await WaitActive(subscription.Id); + var amount = await fixture.WithDb(db => db.Payments.Where(x => x.SubscriptionId == subscription.Id).Select(x => x.Amount).SingleAsync()); + Assert.Equal(1990m, amount); + } + + [Fact] + public async Task Twenty_parallel_retries_create_one_payment_and_replay_identical_response() + { + var user = await User(); + var key = Guid.NewGuid(); + var responses = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => Create(user.Id, key))); + Assert.All(responses, response => Assert.Equal(HttpStatusCode.Accepted, response.StatusCode)); + var bodies = await Task.WhenAll(responses.Select(x => x.Content.ReadAsStringAsync())); + Assert.Single(bodies.Distinct()); + var count = await fixture.WithDb(db => db.Payments.CountAsync(x => x.Subscription.UserId == user.Id)); + Assert.Equal(1, count); + } + + [Fact] + public async Task Key_reuse_with_another_payload_returns_conflict() + { + var user = await User(); + var key = Guid.NewGuid(); + Assert.Equal(HttpStatusCode.Accepted, (await Create(user.Id, key)).StatusCode); + var response = await Create(user.Id, key, "pro"); + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType!.MediaType); + Assert.Equal(1, await fixture.WithDb(db => db.Payments.CountAsync(x => x.Subscription.UserId == user.Id))); + } + + [Fact] + public async Task Saturated_api_returns_429_but_liveness_still_works() + { + var user = await User(); + var key = Guid.NewGuid(); + var lockId = BitConverter.ToInt64(SHA256.HashData(key.ToByteArray()), 0); + await using var scope = fixture.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await using var tx = await db.Database.BeginTransactionAsync(); + await db.Database.ExecuteSqlInterpolatedAsync($"SELECT pg_advisory_xact_lock({lockId})"); + var pending = Enumerable.Range(0, 24).Select(_ => Create(user.Id, key)).ToArray(); + try + { + var deadline = DateTime.UtcNow.AddSeconds(10); + var waiting = 0; + while (DateTime.UtcNow < deadline && waiting < 24) + { + waiting = await db.Database.SqlQueryRaw( + "SELECT count(*)::int AS \"Value\" FROM pg_locks WHERE locktype = 'advisory' AND NOT granted").SingleAsync(); + if (waiting < 24) await Task.Delay(50); + } + Assert.Equal(24, waiting); + var rejected = await Client.GetAsync($"/api/subscriptions?userId={user.Id}"); + Assert.Equal(HttpStatusCode.TooManyRequests, rejected.StatusCode); + Assert.Equal("application/problem+json", rejected.Content.Headers.ContentType!.MediaType); + Assert.Equal(HttpStatusCode.OK, (await Client.GetAsync("/health/live")).StatusCode); + } + finally { await tx.RollbackAsync(); } + Assert.All(await Task.WhenAll(pending), response => Assert.Equal(HttpStatusCode.Accepted, response.StatusCode)); + Assert.Equal(1, await fixture.WithDb(context => context.Payments.CountAsync(x => x.Subscription.UserId == user.Id))); + } + + [Fact] + public async Task Missing_user_rolls_back_without_reserving_key() + { + var key = Guid.NewGuid(); + Assert.Equal(HttpStatusCode.NotFound, (await Create(int.MaxValue, key)).StatusCode); + Assert.False(await fixture.WithDb(db => db.IdempotencyRecords.AnyAsync(x => x.Key == key))); + var user = await User(); + Assert.Equal(HttpStatusCode.Accepted, (await Create(user.Id, key)).StatusCode); + } + + [Fact] + public async Task Failure_after_payment_insert_rolls_back_payment_subscription_and_outbox() + { + var user = await User(); + var key = Guid.NewGuid(); + fixture.Fault.Enabled = true; + try { Assert.Equal(HttpStatusCode.InternalServerError, (await Create(user.Id, key)).StatusCode); } + finally { fixture.Fault.Enabled = false; } + Assert.NotEqual(Guid.Empty, fixture.Fault.FailedMessageId); + Assert.False(await fixture.WithDb(db => db.Set().AnyAsync(x => x.MessageId == fixture.Fault.FailedMessageId))); + Assert.Equal(0, await fixture.WithDb(db => db.Subscriptions.CountAsync(x => x.UserId == user.Id))); + Assert.Equal(0, await fixture.WithDb(db => db.Payments.CountAsync(x => x.Subscription.UserId == user.Id))); + Assert.False(await fixture.WithDb(db => db.IdempotencyRecords.AnyAsync(x => x.Key == key))); + // The same key must remain usable after rollback. + Assert.Equal(HttpStatusCode.Accepted, (await Create(user.Id, key)).StatusCode); + } + + [Theory] + [InlineData(0, "basic")] + [InlineData(1, "free")] + [InlineData(1, "")] + public async Task Invalid_input_returns_400(int userId, string plan) + { + Assert.Equal(HttpStatusCode.BadRequest, (await Create(userId, Guid.NewGuid(), plan)).StatusCode); + } + + [Fact] + public async Task Missing_idempotency_header_returns_400() + { + var response = await Client.PostAsJsonAsync("/api/subscriptions/create", new { userId = 1, plan = "basic" }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Email_is_unique_case_insensitively() + { + var user = await User(); + var response = await Client.PostAsJsonAsync("/api/users", new { name = "Other", email = user.Email.ToUpperInvariant() }); + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + [Fact] + public async Task Client_cannot_override_charge_amount() + { + var user = await User(); + var request = new HttpRequestMessage(HttpMethod.Post, "/api/subscriptions/create") + { Content = JsonContent.Create(new { userId = user.Id, plan = "pro", amount = 1 }) }; + request.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString()); + Assert.Equal(HttpStatusCode.BadRequest, (await Client.SendAsync(request)).StatusCode); + Assert.Equal(0, await fixture.WithDb(db => db.Payments.CountAsync(x => x.Subscription.UserId == user.Id))); + } + + [Fact] + public async Task Pagination_is_bounded_and_has_no_duplicate_rows() + { + var user = await User(); + for (var i = 0; i < 3; i++) Assert.Equal(HttpStatusCode.Accepted, (await Create(user.Id, Guid.NewGuid())).StatusCode); + var first = (await Client.GetFromJsonAsync($"/api/subscriptions?userId={user.Id}&limit=2"))!; + var second = (await Client.GetFromJsonAsync($"/api/subscriptions?userId={user.Id}&limit=2&afterId={first[^1].Id}"))!; + Assert.Equal(2, first.Length); + Assert.Single(second); + Assert.True(second[0].Id > first[^1].Id); + Assert.Equal(HttpStatusCode.BadRequest, (await Client.GetAsync($"/api/subscriptions?userId={user.Id}&limit=101")).StatusCode); + } + + [Fact] + public async Task Duplicate_business_event_with_different_message_id_does_not_duplicate_payment() + { + var user = await User(); + var result = (await (await Create(user.Id, Guid.NewGuid())).Content.ReadFromJsonAsync())!; + await WaitActive(result.Id); + var messageId = Guid.NewGuid(); + // Publish directly, deliberately bypassing request-scoped bus outbox. + await fixture.Services.GetRequiredService().Publish(new SubscriptionPaidEvent + { SubscriptionId = result.Id, Amount = 990m, PaidAt = DateTime.UtcNow }, ctx => ctx.MessageId = messageId); + var deadline = DateTime.UtcNow.AddSeconds(15); + while (DateTime.UtcNow < deadline && !await fixture.WithDb(db => db.Set().AnyAsync(x => x.MessageId == messageId && x.Consumed != null))) + await Task.Delay(100); + Assert.True(await fixture.WithDb(db => db.Set().AnyAsync(x => x.MessageId == messageId && x.Consumed != null))); + Assert.Equal(1, await fixture.WithDb(db => db.Payments.CountAsync(x => x.SubscriptionId == result.Id))); + await WaitActive(result.Id); + } + + [Theory] + [InlineData("basic", 990)] + [InlineData("pro", 1990)] + public void Catalog_owns_charge_amount(string plan, int price) => Assert.Equal(price, PlanCatalog.Price(plan)); + + [Fact] + public void Catalog_rejects_unknown_plan() => Assert.Throws(() => PlanCatalog.Price("free")); +} From f07d1ecf380088b55a6b4e268e28ba7947c4b98c Mon Sep 17 00:00:00 2001 From: Stepan Date: Sat, 3 Oct 2026 19:56:15 +0500 Subject: [PATCH 2/2] Keep API ready during broker outages --- README.md | 1 + Subscription & Billing/Program.cs | 5 ++++- docs/architecture.md | 6 +++--- docs/verification.md | 7 +++++++ scripts/verify.ps1 | 4 ++++ 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2b176a0..575f913 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ flowchart LR prefetch 32, 8 обработчиков на worker; индекс и cursor pagination с лимитом 100. - **Эксплуатация:** отдельные API/worker, миграции перед запуском, контейнер без root, JSON-логи, `traceId` в ProblemDetails, `/health/live` и `/health/ready`. + API остаётся готовым при сбое брокера, пока доступна БД; worker требует брокер. ## Попробовать diff --git a/Subscription & Billing/Program.cs b/Subscription & Billing/Program.cs index b1942be..24e4614 100644 --- a/Subscription & Billing/Program.cs +++ b/Subscription & Billing/Program.cs @@ -93,7 +93,10 @@ app.MapHealthChecks("/health/live", new HealthCheckOptions { Predicate = _ => false }); app.MapHealthChecks("/health/ready", new HealthCheckOptions { - Predicate = check => check.Tags.Contains("ready") || check.Tags.Contains("masstransit") + // API can durably accept writes through the bus outbox while RabbitMQ is down. + // Workers need the transport to make progress, so include it for those roles. + Predicate = check => check.Name == "postgres" || + (role is "Worker" or "All" && check.Tags.Contains("masstransit")) }); if (role is "Api" or "All") { diff --git a/docs/architecture.md b/docs/architecture.md index 0e56d67..816386c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -91,9 +91,9 @@ Outbox не обещает exactly-once транспорт. Inbox отсеива - Очереди и outbox конечны по ресурсам. Нужны алерты на возраст/число outbox, глубину очереди, error queue, latency, 429/5xx, CPU и соединения БД. RabbitMQ UI доступен на localhost:15672; JSON-логи показывают ID подписки и ключ. -- `/health/live` отвечает за процесс; `/health/ready` проверяет БД и MassTransit. - Во время outage readiness падает, хотя API всё ещё может принять запись в outbox. - Балансировщику production нужно выбрать политику доступности при degraded broker. +- `/health/live` отвечает за процесс; `/health/ready` у API проверяет БД и остаётся + успешным при сбое брокера: запись сохраняется в outbox. У worker readiness также + проверяет MassTransit, так как обработка событий требует брокер. Нагрузочную ёмкость измеряют отдельно на оговорённом железе. Для теста записи нужны разные ключи и пользователи, иначе будет измеряться replay или сериализация одного diff --git a/docs/verification.md b/docs/verification.md index 2b40c06..bcca25f 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -1,5 +1,12 @@ # Проверка от 29 сентября 2026 +## Повторная проверка 3 октября 2026 + +- У API readiness теперь зависит от PostgreSQL, а у worker также от MassTransit. +- Сборка без ошибок и предупреждений; 17 интеграционных тестов прошли. +- Smoke-проверка подтвердила HTTP 200 на `/health/ready` при выключенном RabbitMQ, + в том числе после перезапуска API, и последующую доставку из outbox. + ## Функциональность - `dotnet build`: 0 ошибок, 0 предупреждений. diff --git a/scripts/verify.ps1 b/scripts/verify.ps1 index 33d0883..7335ffb 100644 --- a/scripts/verify.ps1 +++ b/scripts/verify.ps1 @@ -40,6 +40,8 @@ if ($repeat.Content -ne $first.Content) { throw 'Replay response changed.' } # A live outage verifies durable storage, process restart and eventual delivery. try { Docker-Step @('compose', 'stop', 'rabbitmq') + $apiReady = Invoke-WebRequest "$BaseUrl/health/ready" -TimeoutSec 5 + if ($apiReady.StatusCode -ne 200) { throw 'API should remain ready with RabbitMQ stopped.' } $headers['Idempotency-Key'] = [guid]::NewGuid().ToString() $pending = Invoke-RestMethod "$BaseUrl/api/subscriptions/create" -Method Post -ContentType 'application/json' -Headers $headers -Body $body -TimeoutSec 15 if ($pending.isActive) { throw 'Subscription unexpectedly activated with broker stopped.' } @@ -50,6 +52,8 @@ try { try { $replay = Invoke-RestMethod "$BaseUrl/api/subscriptions/create" -Method Post -ContentType 'application/json' -Headers $headers -Body $body -TimeoutSec 3 if ($replay.id -ne $pending.id) { throw 'Replay after process restart returned another ID.' } + $apiReady = Invoke-WebRequest "$BaseUrl/health/ready" -TimeoutSec 3 + if ($apiReady.StatusCode -ne 200) { throw 'Restarted API is unready while only RabbitMQ is down.' } $restarted = $true break } catch { Start-Sleep -Milliseconds 300 }