food-market/docs/DEVELOPER-GUIDE.md
nns 97e26a65d5 docs(s12): ARCHITECTURE/MULTI-TENANCY/RUNBOOK/DEVELOPER-GUIDE + k6 baseline + stage-verify CI
Документация для следующего разработчика (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>
2026-06-07 03:19:25 +05:00

440 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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) — где живут секреты.