Документация для следующего разработчика (4 файла, ~1500 строк по
существу), реальный нагрузочный baseline на stage, и автоматический
smoke на каждый push.
Доки:
- docs/ARCHITECTURE.md — карта слоёв, модулей, Program.cs composition
root, полный поток signup→post с трассировщиком ASP.NET pipeline.
- docs/MULTI-TENANCY.md — ITenantEntity + reflection query-filter,
stamping в SaveChanges, SuperAdmin override (read-only + edit-mode
с reason), 8 подводных камней, чеклист «как добавить tenant-сущность».
- docs/RUNBOOK.md — health-чеки, backup/restore с примером, смена SDK,
disaster-recovery на новый сервер, 6 описанных инцидентов
(включая docker-compose project name), БД-troubleshooting.
- docs/DEVELOPER-GUIDE.md — локальный setup, гочи integration-тестов,
полные паттерны (controller с permission + tenant-сущность с
RowVersion + 5 шагов миграции), валидация, structured-логирование,
«НЕ делать» список.
k6 baseline:
- tests/load/ — 3 скрипта (signup-burst, retail-sales-parallel,
sales-report-heavy) + README с инструкциями.
- docs/performance-baseline.md — реальные цифры на stage:
* signup p95 446ms @ 50 RPM (IP-лимит 60/мин держит);
* retail-sale sequential — 17/sec, p95 71ms;
* retail-sale @ VU>1 — 53% failure из-за race в
GenerateNumberAsync (unique-violation 23505 не ловится в
SaveOrFkErrorAsync) — P0 для следующего рефакторинга;
* reports на 1500 чеков — p95 50-114ms до VU=5.
CI:
- .forgejo/workflows/stage-verify.yml — on workflow_run после Docker
API/Web, wait-for-ready → tests/stage-smoke.sh → Telegram пинг.
- tests/stage-smoke.sh — 7-секундный bash-смок (curl+jq+python3),
5 этапов: health, signup, token, multi-tenant изоляция (B → 404
на product A, B → пустой список), полный документ-цикл
(supplier+supply.post → stock=100 → sale.post → stock=99).
Локальный прогон против stage — все этапы зелёные.
Build чистый, локальный прогон smoke зелёный. Sprint 12 закрывает
автономно-безопасный цикл — дальше нужен вход от user'а.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
19 KiB
Developer guide — food-market
Как поднять проект, что куда добавлять, какие паттерны соблюдать. Предполагается, что вы прочитали ARCHITECTURE.md и понимаете слои.
Локальный setup
Что нужно
- .NET 8 SDK 8.0.4xx (см.
global.json,rollForward: latestFeature— годится любой 8.0.4xx). - Node 20+ и pnpm 9+ (для web).
- PostgreSQL 14+ — на macOS обычно brew
postgresql@14. БД:food_market, ownernns, пароль пустой. - Docker + Docker Compose — только для integration-тестов (Testcontainers) и stage-деплоя.
Поднять с нуля
git clone http://127.0.0.1:3000/nns/food-market.git
cd food-market
# 1) БД (если ещё нет)
createdb -O nns food_market # пользователь nns должен существовать
# 2) Backend
ASPNETCORE_ENVIRONMENT=Development \
dotnet run --project src/food-market.api
# первый запуск: применит миграции, посеит справочники, создаст
# SuperAdmin admin@food-market.local / Admin12345!.
# API на http://localhost:5081, Swagger на /swagger.
# 3) Web (в другом терминале)
cd src/food-market.web
pnpm install
pnpm dev
# http://localhost:5173
# 4) Smoke
curl http://localhost:5081/health
# и зайти в браузере, залогиниться admin@food-market.local
Получить токен из CLI
TOKEN=$(curl -sX POST http://localhost:5081/connect/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d 'grant_type=password&username=admin@food-market.local&password=Admin12345!&client_id=food-market-web&scope=openid profile email roles api offline_access' \
| jq -r .access_token)
curl -sH "Authorization: Bearer $TOKEN" http://localhost:5081/api/me | jq
Запуск тестов
# Unit-тесты (быстрые, ~7-10с)
dotnet test tests/food-market.UnitTests/
# Integration (тянут Postgres-контейнер, ~30-60с на холодную)
dotnet test tests/food-market.IntegrationTests/
# Фильтр по имени класса/метода
dotnet test tests/... --filter "FullyQualifiedName~Fiscal"
# Web — type-check + production build
cd src/food-market.web && pnpm exec tsc --noEmit && pnpm build
# E2E (Playwright против stage)
cd tests/e2e && pnpm install
pnpm playwright test stage-smoke.spec.ts
Гочи integration-тестов
- Testcontainers Ryuk выключен (env
TESTCONTAINERS_RYUK_DISABLED=true). Причина: контейнер reaper тянет образ с Docker Hub, на dev-vm сеть туда нестабильна. - Rate-limiter выключен через
RateLimiting__Enabled=false. Он читает конфиг ЭАГЕРНО при регистрации сервисов — поэтому только через переменную окружения (см. memorytest_suites_setup). - Hangfire-сервер выключен (
Hangfire__Enabled=false) — иначе создаёт схему и держит коннект в одноразовом контейнере. - Один
ApiFactoryна всю xUnit-сессию через[Collection(ApiCollection.Name)]. Делать второйWebApplicationFactory<Program>параллельно нельзя —HostFactoryResolverсломается.
Конвенции репо
- C# 12,
Nullableenabled,ImplicitUsingsenabled. - Названия неймспейсов —
foodmarket.Domain,foodmarket.Application,foodmarket.Infrastructure,foodmarket.Api. Папки вsrc/—food-market.api,food-market.application, … (с дефисом). Это расхождение исторически — менять не нужно. - Названия таблиц в БД — snake_case (явный
b.ToTable("retail_sales")), столбцы — PascalCase из C# (EF default), индексы поIX_<table>_<cols>(EF default). - Комментарии в коде — рассказывающие почему, не что. Если из имени переменной/метода не понятно — переименуй; если из логики не понятно, почему — комментируй.
- XML-doc на public API в Application/Infrastructure обязателен (даёт IntelliSense для другой стороны и появляется в Swagger).
- Локализация UI —
src/lib/i18n.ts(русский по умолчанию, есть заготовка под KZ — нужен переводчик).
Паттерны: добавить controller с permission
Пример: POST /api/loyalty/programs (создание программы лояльности),
доступно только Admin'у орги или SuperAdmin'у в edit-mode.
// food-market.api/Controllers/Loyalty/LoyaltyProgramsController.cs
[ApiController]
[Authorize]
[Route("api/loyalty/programs")]
public class LoyaltyProgramsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
private readonly ILogger<LoyaltyProgramsController> _log;
public LoyaltyProgramsController(
AppDbContext db, ITenantContext tenant, ILogger<LoyaltyProgramsController> log)
{
_db = db; _tenant = tenant; _log = log;
}
public record ProgramInput(
[Required] string Name,
[Range(1, 4)] int Type,
[Range(0, 1000)] decimal Rate,
bool IsActive);
[HttpPost, RequiresPermission("LoyaltyEdit")]
public async Task<ActionResult<Guid>> Create(
[FromBody] ProgramInput input, CancellationToken ct)
{
var p = new LoyaltyProgram
{
Name = input.Name.Trim(),
Type = (LoyaltyProgramType)input.Type,
Rate = input.Rate,
IsActive = input.IsActive,
// OrganizationId stamping применит в SaveChanges
};
_db.LoyaltyPrograms.Add(p);
await _db.SaveChangesAsync(ct);
_log.LogInformation(
"Loyalty program created: {ProgramId} {Name} org={OrgId}",
p.Id, p.Name, _tenant.OrganizationId);
return Ok(p.Id);
}
}
Что произошло:
[Authorize]— JWT-токен обязателен (валидируется OpenIddict).[Route("api/...")]— обычный REST-маршрут.api/префикс обязателен для всех контроллеров (web-фронт ходит через/api/*, nginx это знает).[RequiresPermission("LoyaltyEdit")]— резолвится в policyperm:LoyaltyEdit, handler проверяетRolePermissions.LoyaltyEditбулеву у роли текущего юзера. Добавь поле вRolePermissions.csесли ещё нет, миграция-AddColumnдляbool LoyaltyEdit NOT NULL DEFAULT false- апдейт admin-роли в сидере.
ProgramInput— record с DataAnnotations. Для сложной валидации — отдельный FluentValidationAbstractValidator<ProgramInput>вfood-market.api/Infrastructure/Validation/Validators.cs(см. паттерны там)._db.LoyaltyPrograms.Add(p)без явногоOrganizationId—StampTenantвSaveChangesAsyncподставит.- Логирование структурированное:
{ProgramId},{Name},{OrgId}— Serilog кладёт их как property'и (search'абельны в Loki/ES, не строкой).
Если нужен Admin-only (грубее)
[HttpPut, Authorize(Roles = "Admin")]
это эквивалентно «или Identity-role Admin, или SuperAdmin в режиме
override (через SuperAdminOverrideClaimsTransformer)». Подходит для
редких операций; для регулярных используй RequiresPermission.
Паттерны: добавить сущность с RowVersion и tenant
Допустим, нужна новая сущность PromoCode.
1. Domain
// food-market.domain/Sales/PromoCode.cs
public class PromoCode : TenantEntity, IVersionedEntity
{
public uint Xmin { get; set; }
public string Code { get; set; } = "";
public decimal Discount { get; set; }
public DateTime? ExpiresAt { get; set; }
public bool IsActive { get; set; } = true;
}
TenantEntity даёт Id, CreatedAt, UpdatedAt, OrganizationId.
IVersionedEntity + Xmin — оптимистичная блокировка через PG xmin.
2. EF Configuration
// food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs
b.Entity<PromoCode>(e =>
{
e.ToTable("promo_codes");
e.UseXminAsConcurrencyToken();
e.Ignore(x => x.Xmin);
e.Property(x => x.Code).HasMaxLength(40).IsRequired();
e.Property(x => x.Discount).HasPrecision(18, 4);
e.HasIndex(x => new { x.OrganizationId, x.Code }).IsUnique(); // ← OrganizationId первым!
e.HasIndex(x => new { x.OrganizationId, x.IsActive });
});
Важно: индексы — с OrganizationId первым полем. Все запросы пройдут
через query filter и будут фильтроваться по этому полю; без правильного
индекса PG будет full-scan тенант-таблицы.
3. DbSet
// food-market.infrastructure/Persistence/AppDbContext.cs
public DbSet<PromoCode> PromoCodes => Set<PromoCode>();
4. Миграция руками
// food-market.infrastructure/Persistence/Migrations/20260608100000_PromoCodes.cs
[DbContext(typeof(AppDbContext))]
[Migration("20260608100000_PromoCodes")]
public partial class PromoCodes : Migration
{
protected override void Up(MigrationBuilder b)
{
b.CreateTable(
name: "promo_codes",
schema: "public",
columns: t => new
{
Id = t.Column<Guid>(type: "uuid", nullable: false),
OrganizationId = t.Column<Guid>(type: "uuid", nullable: false),
Code = t.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
Discount = t.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
ExpiresAt = t.Column<DateTime>(type: "timestamp with time zone", nullable: true),
IsActive = t.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
CreatedAt = t.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = t.Column<DateTime>(type: "timestamp with time zone", nullable: true),
},
constraints: t => t.PrimaryKey("PK_promo_codes", x => x.Id));
b.CreateIndex(
name: "IX_promo_codes_OrganizationId_Code",
schema: "public", table: "promo_codes",
columns: new[] { "OrganizationId", "Code" }, unique: true);
b.CreateIndex(
name: "IX_promo_codes_OrganizationId_IsActive",
schema: "public", table: "promo_codes",
columns: new[] { "OrganizationId", "IsActive" });
}
protected override void Down(MigrationBuilder b)
=> b.DropTable("promo_codes", "public");
}
Обязательно: [DbContext] + [Migration("...")] атрибуты — без них
db.Database.Migrate() миграцию не подхватит (memory:
feedback_ef_migrations).
5. Тест на изоляцию
Добавить case в TenantIsolationTests: org A создаёт PromoCode, org B
делает GET, видит пустой список.
Валидация
Простые правила — DataAnnotations
public record ProductInput(
[Required, MaxLength(200)] string Name,
[Range(0, 1e10)] decimal Price);
Сложные — FluentValidation
В food-market.api/Infrastructure/Validation/Validators.cs:
public sealed class ProductInputValidator : AbstractValidator<ProductInput>
{
public ProductInputValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.Price).GreaterThanOrEqualTo(0);
// Кросс-полевые правила, async, реализующие бизнес-инвариант
RuleFor(x => x.Lines).NotEmpty().WithMessage("Хотя бы одна позиция.");
RuleForEach(x => x.Lines).ChildRules(line =>
{
line.RuleFor(l => l.Quantity).GreaterThan(0);
line.RuleFor(l => l.UnitPrice).GreaterThanOrEqualTo(0);
});
}
}
Валидаторы регистрируются автоматически через
AddValidatorsFromAssemblyContaining<Program>(). ValidationFilter
(глобальный action-filter в Program.cs) запускает их на каждом
action и возвращает 400 ProblemDetails (RFC 7807).
Бизнес-валидация (требует БД)
Если правило требует справиться с БД (например, «склад существует и не архивирован»), вынесите в первый шаг action-метода:
[HttpPost]
public async Task<ActionResult> Create(ProductInput input, CancellationToken ct)
{
var groupOk = await _db.ProductGroups.AnyAsync(g => g.Id == input.ProductGroupId, ct);
if (!groupOk)
return BadRequest(new { error = "Группа не найдена.", field = "productGroupId" });
// ...
}
Формат ошибки — { error: "...", field?: "..." }. Фронт показывает
error тостом, field подсвечивает в форме.
Логирование
Используем Serilog со структурированными полями. LogEnrichmentMiddleware
уже добавляет CorrelationId/OrgId/UserId в каждую запись.
Правила
- Не строкой:
_log.LogInformation("Created product " + id)— нет._log.LogInformation("Product created: {ProductId} name={Name}", id, name)— да. - Уровень:
Trace/Debug— только для отладки конкретного бага.Information— успешные mutate-операции, важные events (post/unpost документа, регистрация чека в ОФД).Warning— что-то пошло не как ожидалось, но обработали (best-effort fail, retry-able ошибка).Error— обработать не удалось, нужен внимательный человек.Critical— приложение в плохом состоянии, может перестать работать.
- Не логировать PII в открытом виде (пароли, токены, email — email можно, но не светить лишний раз).
- Exception как первый аргумент:
_log.LogError(ex, "...", ...), не_log.LogError("... " + ex.Message)— теряется stack trace.
Пример из RetailSalesController
_log.LogInformation(
"RetailSale posted: {SaleNumber} store={StoreId} payment={Payment} lines={LinesCount} total={Total}",
sale.Number, sale.StoreId, sale.Payment, sale.Lines.Count, sale.Total);
// ...
try
{
await _notify.PublishAsync(...);
}
catch (Exception ex)
{
// Notification — best-effort: не должна валить транзакцию (она уже закоммичена)
_log.LogWarning(ex, "SignalR notify failed for sale {SaleId}", sale.Id);
}
SignalR realtime
Если нужно отправить уведомление на фронт (инвалидация query'я, показ тоста):
// в Program.cs INotificationsPublisher уже зарегистрирован
public class MyController : ControllerBase
{
private readonly INotificationsPublisher _notify;
[HttpPost("...")]
public async Task<IActionResult> Action(...)
{
// ... business logic ...
await _notify.PublishAsync(
organizationId,
NotificationEvents.SalePosted, // строковая константа
new SalePostedPayload(...)); // record DTO
return NoContent();
}
}
На фронте — useNotifications() хук подписан на хаб и инвалидирует
relevant query'и. Новые event'ы добавлять в NotificationEvents,
payload — в соседнем record'е.
Что НЕ делать
- НЕ инжектить
IServiceProviderчтобы доставать сервисы лениво — объяви явные зависимости в конструкторе. Исключение: фабрики (Fiscal, Email) которые открывают scope для свежего DbContext'а. - НЕ писать raw SQL (
SqlQueryRaw/ExecuteSqlRaw) без явногоWHERE OrganizationId = @org— query-filter не применится. - НЕ менять снапшот в
Persistence/Migrations/AppDbContextModelSnapshot.csруками для добавления новых полей — он используется только инструментомdotnet ef migrations add, который мы не запускаем. Trying to add partial state ломает только инструмент, ничего не дав. Если хочется — обновляй целиком, синхронно с моделью; иначе оставь как есть. - НЕ добавлять платные компоненты: Kendo, DevExpress, Syncfusion commercial, Telerik (CLAUDE.md).
- НЕ менять
global.jsonбез согласования (CLAUDE.md). - НЕ создавать миграции через
dotnet ef migrations add— пиши руками (memory:feedback_ef_migrations). - НЕ делать
git push --forceна main (Forgejo — primary).
Полезные ссылки
- ARCHITECTURE.md — слои и модули.
- MULTI-TENANCY.md — query-filter, override-режим.
- RUNBOOK.md — операционные процедуры.
- openapi.md — генерация TS-клиента из Swagger.
- observability.md — Serilog + Prometheus.
- ofd-integration.md — ОФД-провайдеры.
- secrets.md — где живут секреты.