Документация для следующего разработчика (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>
440 lines
19 KiB
Markdown
440 lines
19 KiB
Markdown
# Developer guide — food-market
|
||
|
||
Как поднять проект, что куда добавлять, какие паттерны соблюдать.
|
||
Предполагается, что вы прочитали [ARCHITECTURE.md](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`, owner `nns`, пароль пустой.
|
||
- **Docker** + **Docker Compose** — только для integration-тестов
|
||
(Testcontainers) и stage-деплоя.
|
||
|
||
### Поднять с нуля
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
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
|
||
```
|
||
|
||
## Запуск тестов
|
||
|
||
```bash
|
||
# 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`. Он
|
||
читает конфиг ЭАГЕРНО при регистрации сервисов — поэтому только через
|
||
переменную окружения (см. memory `test_suites_setup`).
|
||
- **Hangfire-сервер** выключен (`Hangfire__Enabled=false`) — иначе
|
||
создаёт схему и держит коннект в одноразовом контейнере.
|
||
- Один `ApiFactory` на всю xUnit-сессию через `[Collection(ApiCollection.Name)]`.
|
||
Делать второй `WebApplicationFactory<Program>` параллельно нельзя —
|
||
`HostFactoryResolver` сломается.
|
||
|
||
## Конвенции репо
|
||
|
||
- C# 12, `Nullable` enabled, `ImplicitUsings` enabled.
|
||
- Названия неймспейсов — `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.
|
||
|
||
```csharp
|
||
// 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")]` — резолвится в policy `perm:LoyaltyEdit`,
|
||
handler проверяет `RolePermissions.LoyaltyEdit` булеву у роли
|
||
текущего юзера. Добавь поле в `RolePermissions.cs` если ещё нет,
|
||
миграция-`AddColumn` для `bool LoyaltyEdit NOT NULL DEFAULT false`
|
||
+ апдейт admin-роли в сидере.
|
||
- `ProgramInput` — record с DataAnnotations. Для сложной валидации —
|
||
отдельный FluentValidation `AbstractValidator<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 (грубее)
|
||
|
||
```csharp
|
||
[HttpPut, Authorize(Roles = "Admin")]
|
||
```
|
||
|
||
это эквивалентно «или Identity-role Admin, или SuperAdmin в режиме
|
||
override (через `SuperAdminOverrideClaimsTransformer`)». Подходит для
|
||
редких операций; для регулярных используй `RequiresPermission`.
|
||
|
||
## Паттерны: добавить сущность с RowVersion и tenant
|
||
|
||
Допустим, нужна новая сущность `PromoCode`.
|
||
|
||
### 1. Domain
|
||
|
||
```csharp
|
||
// 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
|
||
|
||
```csharp
|
||
// 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
|
||
|
||
```csharp
|
||
// food-market.infrastructure/Persistence/AppDbContext.cs
|
||
public DbSet<PromoCode> PromoCodes => Set<PromoCode>();
|
||
```
|
||
|
||
### 4. Миграция руками
|
||
|
||
```csharp
|
||
// 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
|
||
|
||
```csharp
|
||
public record ProductInput(
|
||
[Required, MaxLength(200)] string Name,
|
||
[Range(0, 1e10)] decimal Price);
|
||
```
|
||
|
||
### Сложные — FluentValidation
|
||
|
||
В `food-market.api/Infrastructure/Validation/Validators.cs`:
|
||
|
||
```csharp
|
||
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-метода:
|
||
|
||
```csharp
|
||
[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
|
||
|
||
```csharp
|
||
_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'я,
|
||
показ тоста):
|
||
|
||
```csharp
|
||
// в 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](ARCHITECTURE.md) — слои и модули.
|
||
- [MULTI-TENANCY.md](MULTI-TENANCY.md) — query-filter, override-режим.
|
||
- [RUNBOOK.md](RUNBOOK.md) — операционные процедуры.
|
||
- [openapi.md](openapi.md) — генерация TS-клиента из Swagger.
|
||
- [observability.md](observability.md) — Serilog + Prometheus.
|
||
- [ofd-integration.md](ofd-integration.md) — ОФД-провайдеры.
|
||
- [secrets.md](secrets.md) — где живут секреты.
|