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>
This commit is contained in:
parent
0d3ef81f72
commit
97e26a65d5
70
.forgejo/workflows/stage-verify.yml
Normal file
70
.forgejo/workflows/stage-verify.yml
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
name: Stage verify
|
||||
|
||||
# Запускается ПОСЛЕ успешного docker-api или docker-web — они уже
|
||||
# собирают и деплоят на stage. Эта работа делает быстрый smoke
|
||||
# (~30с): auth, multi-tenant изоляция, один полный документ-цикл
|
||||
# (signup → seed → supply.post → retail-sale.post → проверка остатка).
|
||||
#
|
||||
# Если падает — пинг в Telegram. По дефолту в notify.yml уже есть
|
||||
# perfailure нотификация для CI/Docker — этот workflow добавляет к ним.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Docker API", "Docker Web"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
|
||||
# Не запускаемся, если триггерный workflow упал — нет смысла верифировать
|
||||
# то что не задеплоилось.
|
||||
jobs:
|
||||
smoke:
|
||||
name: Smoke против stage
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
|
||||
runs-on: [self-hosted, linux]
|
||||
env:
|
||||
BASE_URL: https://test.admin.food-market.kz
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Wait for health/ready
|
||||
run: |
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if curl -fsS "$BASE_URL/health/ready" | grep -q '"status":"Healthy"'; then
|
||||
echo "Stage ready"
|
||||
exit 0
|
||||
fi
|
||||
echo "[$i/10] not ready yet, sleeping..."
|
||||
sleep 3
|
||||
done
|
||||
echo "Stage NOT ready after 30s" >&2
|
||||
exit 1
|
||||
|
||||
- name: Run smoke suite
|
||||
env:
|
||||
BASE_URL: ${{ env.BASE_URL }}
|
||||
run: bash tests/stage-smoke.sh
|
||||
|
||||
- name: Notify Telegram on success
|
||||
if: success() && github.event_name == 'workflow_run'
|
||||
env:
|
||||
BOT: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
curl -sS -X POST "https://api.telegram.org/bot$BOT/sendMessage" \
|
||||
--data-urlencode "chat_id=$CHAT" \
|
||||
--data-urlencode "text=✅ stage verify OK — ${SHA:0:7}" \
|
||||
> /dev/null
|
||||
|
||||
- name: Notify Telegram on failure
|
||||
if: failure()
|
||||
env:
|
||||
BOT: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
run: |
|
||||
RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
curl -sS -X POST "https://api.telegram.org/bot$BOT/sendMessage" \
|
||||
--data-urlencode "chat_id=$CHAT" \
|
||||
--data-urlencode "text=❌ stage verify FAILED — ${SHA:0:7} — $RUN_URL" \
|
||||
> /dev/null
|
||||
397
docs/ARCHITECTURE.md
Normal file
397
docs/ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
# food-market — архитектура
|
||||
|
||||
Документ для разработчика, который пришёл в проект первый раз. Описывает
|
||||
слои, модули, ключевые потоки и почему некоторые вещи сделаны именно так.
|
||||
|
||||
Старая короткая версия — `docs/architecture.md` (lowercase). Этот файл
|
||||
заменяет её и расширяет.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **Что**: multi-tenant SaaS-аналог МойСклад для розничных магазинов РК.
|
||||
- **Backend**: .NET 8 LTS, ASP.NET Core, EF Core 8, PostgreSQL 14+ (dev) / 16 (prod).
|
||||
- **Auth**: OpenIddict 5 (password + refresh) поверх ASP.NET Identity.
|
||||
- **Web**: React 19 + Vite + TS, Tailwind v4, shadcn/ui, TanStack Query, AG Grid.
|
||||
- **POS**: WPF на .NET 8 Windows, оффлайн-буфер в SQLite, синк через `/api/pos/v1`.
|
||||
|
||||
## Топология deployment
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Internet / LAN магазина │
|
||||
└───────────┬───────────────────────┬─────────────────────────────┘
|
||||
│ HTTPS │ HTTPS (Bearer) + офлайн-буфер
|
||||
▼ ▼
|
||||
┌────────────────────┐ ┌──────────────────────────┐
|
||||
│ food-market.web │ │ food-market.pos (WPF) │
|
||||
│ React SPA │ │ .NET 8, Windows 10+ │
|
||||
│ admin.fm.kz │ │ локальная SQLite │
|
||||
└─────────┬──────────┘ └──────────┬───────────────┘
|
||||
│ │
|
||||
│ /api/* │ /api/pos/v1/*
|
||||
│ /hubs/notifications │
|
||||
└─────────────┬────────────┘
|
||||
▼
|
||||
┌───────────────────────────────────────────┐
|
||||
│ food-market.api │
|
||||
│ ASP.NET Core + OpenIddict + SignalR │
|
||||
│ - tenant query filters per request │
|
||||
│ - Hangfire scheduler + recurring jobs │
|
||||
│ - /metrics (Prometheus) /health/{live,ready}│
|
||||
└────┬──────────┬───────────┬───────────┬───┘
|
||||
▼ ▼ ▼ ▼
|
||||
┌────────┐ ┌─────────┐ ┌──────────┐ ┌──────────┐
|
||||
│Postgres│ │ Hangfire│ │ MinIO │ │ Logs │
|
||||
│ 16 │ │ (jobs) │ │ (S3, opt)│ │ Serilog │
|
||||
└────────┘ └─────────┘ └──────────┘ └──────────┘
|
||||
│
|
||||
▼
|
||||
локальный FS (/uploads volume)
|
||||
— если MinIO не настроен
|
||||
```
|
||||
|
||||
Stage и prod крутятся через `deploy/docker-compose.yml` на dev-vm
|
||||
(`192.168.1.190`). Локальный dev: API на `:5081`, Postgres из
|
||||
brew (`postgres@14` на `:5432`), web через `pnpm dev` на `:5173`.
|
||||
|
||||
## Структура солюшна
|
||||
|
||||
```
|
||||
food-market/
|
||||
├── src/
|
||||
│ ├── food-market.domain/ ← POCO, enum, доменные интерфейсы
|
||||
│ ├── food-market.application/ ← MediatR-handlers, DTO, абстракции
|
||||
│ ├── food-market.infrastructure/ ← EF Core, Identity, OpenIddict EF, внешние API
|
||||
│ ├── food-market.api/ ← ASP.NET Core host: controllers, middleware, DI
|
||||
│ ├── food-market.web/ ← React SPA
|
||||
│ ├── food-market.shared/ ← DTO-контракты api ↔ pos
|
||||
│ ├── food-market.public/ ← Astro static (маркетинг food-market.kz)
|
||||
│ ├── food-market.pos.core/ ← логика POS (без UI)
|
||||
│ └── food-market.pos/ ← WPF UI (net8.0-windows)
|
||||
├── tests/
|
||||
│ ├── food-market.UnitTests/ ← xUnit + InMemoryDB
|
||||
│ ├── food-market.IntegrationTests/← xUnit + Testcontainers Postgres
|
||||
│ ├── e2e/ ← Playwright (TS), бьёт по test.admin.food-market.kz
|
||||
│ └── load/ ← k6 (Sprint 12)
|
||||
├── deploy/ ← docker-compose, Dockerfile.*, systemd-юниты
|
||||
└── docs/ ← вы здесь
|
||||
```
|
||||
|
||||
### Слои (Clean Architecture)
|
||||
|
||||
| Слой | Зависит от | Что лежит |
|
||||
|-------------------|---------------------------|----------------------------------------------------------------------------------------|
|
||||
| **domain** | ничего | POCO-сущности, enum'ы, доменные интерфейсы (`ITenantEntity`, `IVersionedEntity`). |
|
||||
| **application** | domain + shared | MediatR `IRequest`/`IRequestHandler`, DTO, абстракции (`IFiscalProvider`, `IEmailSender`, `IStockService`, `ITenantContext`), `FluentValidation` валидаторы. |
|
||||
| **infrastructure**| application + domain | `AppDbContext`, Identity-таблицы, OpenIddict EF store, реализации абстракций, HTTP-клиенты к внешним API (Webkassa, MoySklad, MailKit, Telegram). |
|
||||
| **api** | всё перечисленное выше | ASP.NET Core host: контроллеры, middleware, DI-проводка, фоновые джобы (Hangfire), Realtime hub'ы (SignalR), сидеры. |
|
||||
|
||||
Правило одностороннего направления зависимостей: домен не знает про EF и
|
||||
ASP.NET, application — про конкретные провайдеры. Это позволило прикрутить
|
||||
ОФД (Sprint 11) одним интерфейсом + четырьмя реализациями, без правок
|
||||
контроллеров кроме одной точки вызова.
|
||||
|
||||
## Модули backend
|
||||
|
||||
### Domain (`src/food-market.domain/`)
|
||||
|
||||
- `Common/Entity.cs` — базовая `Entity` с `Id/CreatedAt/UpdatedAt`.
|
||||
- `Common/TenantEntity.cs` — `ITenantEntity` (обязательный `OrganizationId`),
|
||||
`TenantEntity` (база), `IOptionalTenantEntity` (системные справочники с
|
||||
`OrganizationId?`).
|
||||
- `Common/IVersionedEntity.cs` — оптимистичная блокировка через PG `xmin`
|
||||
(`Xmin` поле).
|
||||
- Бизнес-сущности по поддоменам: `Catalog/` (Product, Counterparty,
|
||||
ProductGroup, …), `Inventory/` (Stock, StockMovement, Loss, Transfer,
|
||||
Inventory), `Purchases/` (Supply, Enter, SupplierReturn),
|
||||
`Sales/` (RetailSale, RetailSaleLine, Demand, LoyaltyCard,
|
||||
LoyaltyProgram, Promotion), `Organizations/` (Organization, Employee,
|
||||
EmployeeRole, OrgAuditLog, SuperAdminAuditLog), `Platform/`
|
||||
(PlatformSettings — singleton SMTP-конфиг).
|
||||
|
||||
### Application (`src/food-market.application/`)
|
||||
|
||||
- **CQRS на MediatR** — пока partial: образцы в `Purchases/Commands/CreateSupplyCommand.cs`,
|
||||
`Sales/Commands/PostRetailSaleCommand.cs`, `Sales/Queries/GetSalesReportQuery.cs`.
|
||||
Большинство контроллеров пока «толстые» (исторически до TD-1).
|
||||
- **Абстракции**:
|
||||
- `Common/Tenancy/ITenantContext` — `OrganizationId`, `IsSuperAdmin`,
|
||||
`IsTenantOverride`, `UserId`.
|
||||
- `Common/Email/IEmailSender` — отправка через текущий SMTP-конфиг.
|
||||
- `Common/Fiscal/IFiscalProvider` + `IFiscalProviderFactory` (Sprint 11).
|
||||
- `Inventory/IStockService` — единая точка списания/начисления остатка
|
||||
(любая операция, меняющая склад, идёт через `ApplyMovementAsync`).
|
||||
- **FluentValidation** валидаторы рядом с DTO; глобально подключаются
|
||||
через `AddValidatorsFromAssemblyContaining<Program>()`.
|
||||
|
||||
### Infrastructure (`src/food-market.infrastructure/`)
|
||||
|
||||
- `Persistence/AppDbContext.cs` — единый DbContext (тенанта + Identity +
|
||||
OpenIddict EF store). Query-filter применяется через reflection ко всем
|
||||
`ITenantEntity` (см. [MULTI-TENANCY.md](MULTI-TENANCY.md)).
|
||||
- `Persistence/Configurations/*.cs` — EF Core fluent configs по поддоменам.
|
||||
- `Persistence/Migrations/` — миграции пишутся вручную (см. CLAUDE.md /
|
||||
memory `feedback_ef_migrations`), снапшот не синхронизируется
|
||||
с моделью (используется только `dotnet ef migrations add`, который
|
||||
не вызывается в этом проекте).
|
||||
- `Persistence/OrgAuditInterceptor.cs` — EF `ISaveChangesInterceptor`,
|
||||
пишет каждую `Add/Update/Delete` в `org_audit_log` (JSONB diff).
|
||||
- `Identity/` — кастомные `User`, `Role` для ASP.NET Identity.
|
||||
- `Email/MailKitEmailSender.cs` — SMTP через MailKit, конфиг из
|
||||
`PlatformSettings` (читается на каждой отправке через scope).
|
||||
- `Fiscal/` — `IFiscalProvider` реализации: Mock + Webkassa (полный) +
|
||||
Kassa24/OfdSolo (skeleton). См. [ofd-integration.md](ofd-integration.md).
|
||||
- `Inventory/StockService.cs` — единственное место, где двигаются остатки.
|
||||
Бизнес-инвариант: stock = SUM(stock_movements) per (productId, storeId).
|
||||
- `Integrations/MoySklad/` — HTTP-клиент + конвертер для импорта каталога.
|
||||
|
||||
### Api (`src/food-market.api/`)
|
||||
|
||||
- `Program.cs` — composition root (~570 строк, поделён логическими
|
||||
блоками; см. секцию «Composition root» ниже).
|
||||
- `Controllers/` — REST-API. Структура совпадает с маршрутами:
|
||||
- `Auth/` — `/api/auth/*` (signup, forgot-password, 2FA).
|
||||
- `Catalog/` — `/api/catalog/{products,counterparties,…}`.
|
||||
- `Purchases/` — `/api/purchases/{supplies,supplier-returns}`.
|
||||
- `Sales/` — `/api/sales/{retail,demands}`.
|
||||
- `Inventory/` — `/api/inventory/{stock,enters,losses,transfers,inventories}`.
|
||||
- `Reports/` — `/api/reports/{sales,stock,profit,abc}`.
|
||||
- `Dashboard/` — `/api/dashboard/{top-products,low-stock,recent-sales,margin}`.
|
||||
- `Loyalty/`, `Promotions/` — Sprint 9.
|
||||
- `Organizations/` — настройки орги, сотрудники, роли, ОФД.
|
||||
- `Pos/` — `/api/pos/v1/*` для WPF POS (sync, idempotency).
|
||||
- `SuperAdmin/` — `/api/super-admin/*` (управление платформой).
|
||||
- `Admin/` — `/api/admin/*` (per-org admin tools: cleanup, demo-seed,
|
||||
moysklad-import, audit-log просмотр).
|
||||
- `Search/` — глобальный `/api/search/global` (Cmd+K).
|
||||
- `Telegram/` — bind owner-chat, статус.
|
||||
- `Uploads/` — multipart upload изображений.
|
||||
- `Infrastructure/`:
|
||||
- `Tenancy/HttpContextTenantContext.cs` — реализация `ITenantContext`
|
||||
через `IHttpContextAccessor` + AsyncLocal-override для background tasks.
|
||||
- `Tenancy/SuperAdminOverrideClaimsTransformer.cs` — добавляет
|
||||
`Admin/Cashier/Storekeeper` роли SuperAdmin'у с активным
|
||||
`X-Org-Override`, чтобы `[Authorize(Roles="Admin")]` не отшил его.
|
||||
- `Tenancy/ReadonlyOverrideMiddleware.cs` — в режиме override без
|
||||
`X-Org-Override-Reason` блочит любую мутацию (читать всё, писать
|
||||
ничего; писать — только в edit-mode с reason).
|
||||
- `Tenancy/SuperAdminEditAuditFilter.cs` — глобальный action-filter,
|
||||
при mutate-в-override пишет в `super_admin_audit_log`.
|
||||
- `Authorization/RequiresPermissionAttribute.cs` + `PermissionAuthorizationPolicyProvider`
|
||||
+ `PermissionAuthorizationHandler` — permission-based авторизация.
|
||||
`[RequiresPermission("ProductsEdit")]` → policy `perm:ProductsEdit` →
|
||||
`RolePermissions.ProductsEdit` булева на `EmployeeRole`.
|
||||
- `Validation/ValidationFilter.cs` — FluentValidation → 400
|
||||
ProblemDetails (RFC 7807).
|
||||
- `RateLimiting/AuthRateLimiterExtensions.cs` — 5/мин + 20/час на
|
||||
`/connect/token`, `/api/auth/signup` по IP+username.
|
||||
- `Observability/LogEnrichmentMiddleware.cs` — кладёт
|
||||
`CorrelationId/OrgId/UserId` в Serilog `LogContext`, каждая запись
|
||||
в журнале получает эти лейблы.
|
||||
- `Observability/DbMetricsInterceptor.cs` — EF интерсептор, Prometheus
|
||||
`food_market_db_query_duration_seconds`.
|
||||
- `Observability/AppMetrics.cs` — статические Counter'ы (Posted/Unposted
|
||||
per docType, FiscalRegistered, …).
|
||||
- `Health/DatabaseReadyHealthCheck.cs` — `SELECT 1` + проверка
|
||||
`__EFMigrationsHistory`.
|
||||
- `Security/OpenIddictKeyConfigurator.cs` — в dev — persistent RSA в
|
||||
`App_Data/oidc-keys/*`; в stage/prod — X509 PFX из конфига
|
||||
(см. [openiddict-keys.md](openiddict-keys.md)).
|
||||
- `Realtime/NotificationsHub.cs` + `NotificationsPublisher.cs` —
|
||||
SignalR-хаб `/hubs/notifications`, группы per-org. События:
|
||||
`SalePosted`, `LowStock`, `ImportProgress`.
|
||||
- `Background/`:
|
||||
- `HangfireJobsConfigurator` — регистрирует recurring jobs при старте:
|
||||
`prune-stock-movements` (03:30), `prune-audit-log` (03:45),
|
||||
`weekly-summary` (пн 07:00), `low-stock-alert` (08:00),
|
||||
`telegram-owner-daily-summary` (06:00).
|
||||
- `HousekeepingJobs` — pg-cleanup'ы.
|
||||
- `EmailNotificationJobs` — weekly-summary + low-stock email.
|
||||
- `OwnerDailySummaryJob` — Telegram-сводка владельцу.
|
||||
- `ReferencePriceRefreshJob` — пересчёт `Product.ReferencePrice`
|
||||
каждые 30 дней без приёмок.
|
||||
- `Seed/`:
|
||||
- `SystemReferenceSeeder` — справочники (страны, валюты, единицы).
|
||||
- `OpenIddictClientSeeder` — регистрирует client `food-market-web`.
|
||||
- `DevDataSeeder` — dev-only admin user (SuperAdmin).
|
||||
- `DemoTenantSeeder` / `YearDemoSeeder` — заполняют tenant
|
||||
демо-данными (Sprint 5 / Sprint 10).
|
||||
|
||||
### Web (`src/food-market.web/`)
|
||||
|
||||
- Vite + React 19 + TS 6, Tailwind v4. Маршрутизация — React Router 6.
|
||||
- `src/lib/api.ts` — axios instance с auto-refresh токена.
|
||||
- `src/lib/auth.ts` — login/logout, store токена в `localStorage`.
|
||||
- `src/components/` — общие виджеты (Field, Button, Skeleton,
|
||||
CommandPalette, DashboardWidgets).
|
||||
- `src/pages/` — страницы (один файл per route).
|
||||
- TanStack Query — кеширование API-вызовов, инвалидация по SignalR.
|
||||
- AG Grid Community — большие списки (товары, контрагенты, отчёты).
|
||||
|
||||
### POS (`src/food-market.pos*/`)
|
||||
|
||||
- `pos.core/` — логика без UI: оффлайн-буфер, sync, расчёт чека.
|
||||
- `pos/` — WPF UI, CommunityToolkit.Mvvm, SQLite, Refit + Polly,
|
||||
System.IO.Ports для весов CAS.
|
||||
- Sync: батчем по 50 чеков через `POST /api/pos/v1/batch` с
|
||||
`Idempotency-Key`. Сервер дедупит через `pos_batch_acks` уникальный
|
||||
индекс (`OrganizationId, IdempotencyKey`).
|
||||
|
||||
## Composition root (`Program.cs`)
|
||||
|
||||
Логические блоки в порядке регистрации:
|
||||
|
||||
1. **Serilog** bootstrap (до builder).
|
||||
2. **CORS** (`Cors:AllowedOrigins` из конфига).
|
||||
3. **HttpContextAccessor** + `ITenantContext` + `IClaimsTransformation`
|
||||
(SuperAdmin override роли).
|
||||
4. **EF Core**: `AppDbContext` (Npgsql, OpenIddict, два interceptor'а).
|
||||
5. **Identity** + **OpenIddict** server (password + refresh, rolling
|
||||
refresh, leeway = 0).
|
||||
6. **Authentication/Authorization** policies (`AdminAccess`, `perm:*`).
|
||||
7. **Rate-limiter** (`/connect/token`, `/api/auth/signup`).
|
||||
8. **HealthChecks** (`database` тег `ready`).
|
||||
9. **IEmailSender** (Singleton + scope для DbContext).
|
||||
10. **IFiscalProvider** + 3 HttpClient + `IFiscalProviderFactory`.
|
||||
11. **MediatR** (assembly scan), **FluentValidation**.
|
||||
12. **MoySklad HttpClient** + import service.
|
||||
13. **Hangfire** server + storage (PG), `HangfireJobsConfigurator` хостед.
|
||||
14. **SignalR** + `INotificationsPublisher`.
|
||||
15. **Telegram-бот** HttpClient (если token задан).
|
||||
16. **Сидеры**: `OpenIddictClientSeeder`, `SystemReferenceSeeder`,
|
||||
`DevDataSeeder` хостед; `DemoTenantSeeder`/`YearDemoSeeder` scoped.
|
||||
17. `Build()` → middleware pipeline (Serilog→CORS→HttpMetrics→
|
||||
RateLimiter→hubs-token-fix→AuthN→AuthZ→LogEnrichment→
|
||||
ReadonlyOverride→StaticFiles[/uploads]→Swagger→MapControllers→
|
||||
MapHub→MapMetrics→HangfireDashboard→HealthChecks).
|
||||
18. На старте: `db.Database.Migrate()` (идемпотентно).
|
||||
19. `app.Run()`.
|
||||
|
||||
## Поток: signup → bootstrap → первая продажа
|
||||
|
||||
```
|
||||
1. POST /api/auth/signup { email, password, organizationName, phone }
|
||||
─→ создание Organization (Entity, не tenant-scoped)
|
||||
─→ создание AppUser + добавление в роль "Admin"
|
||||
─→ создание Employee с AdminRole и всеми permission'ами
|
||||
─→ создание главного Store (isMain=true) + RetailPoint
|
||||
─→ создание PriceType "Розничная" (isRetail=true, isSystem=true)
|
||||
─→ копирование системных UnitOfMeasure (OrganizationId=null) на org
|
||||
|
||||
2. POST /connect/token { grant_type=password, username, password }
|
||||
─→ OpenIddict проверяет, выдаёт access_token + refresh_token
|
||||
─→ access_token содержит claim org_id и role
|
||||
|
||||
3. GET /api/me (web bootstrap)
|
||||
─→ возвращает { sub, email, roles, orgId, hasLiveOrg, hasActiveEmployee }
|
||||
─→ фронт роутит на /dashboard или /no-organization (orphan-fallback)
|
||||
|
||||
4. POST /api/catalog/products { name, prices, barcodes, ... }
|
||||
─→ ValidationFilter (FluentValidation)
|
||||
─→ controller → _db.Products.Add(...) (OrganizationId stamped в SaveChanges)
|
||||
─→ возвращает Product DTO
|
||||
|
||||
5. POST /api/purchases/supplies + POST /{id}/post
|
||||
─→ post идёт под Serializable tx
|
||||
─→ для каждой строки StockService.ApplyMovementAsync(+qty, MovementType.Supply)
|
||||
─→ Stock row для (productId, storeId) либо создаётся, либо обновляется
|
||||
─→ commit, AppMetrics.IncrementPosted("supply")
|
||||
─→ SignalR: NotificationsHub → группа org → событие SupplyPosted
|
||||
|
||||
6. POST /api/sales/retail + POST /{id}/post
|
||||
─→ Serializable tx, проверка остатка ≥ 0 для каждой строки
|
||||
─→ StockService.ApplyMovementAsync(-qty, MovementType.RetailSale)
|
||||
─→ commit, AppMetrics.IncrementPosted("retail-sale")
|
||||
─→ best-effort TryFiscalizeAsync (Sprint 11) — отдельно, после commit
|
||||
─→ SignalR: SalePosted (dashboard виджеты инвалидируют queries)
|
||||
```
|
||||
|
||||
## База данных
|
||||
|
||||
Postgres 14+ для dev (brew systemwide), Postgres 16 в Docker для
|
||||
stage/prod. Названия таблиц snake_case через явный `ToTable("…")`.
|
||||
|
||||
### Ключевые таблицы
|
||||
|
||||
| Таблица | Назначение |
|
||||
|---|---|
|
||||
| `organizations` | Корневой tenant. Не tenant-scoped. |
|
||||
| `users`, `roles`, `user_roles` | ASP.NET Identity. |
|
||||
| `employees`, `employee_roles`, `role_permissions` | Сотрудники tenant'а + кастомные роли с булевыми флагами прав. |
|
||||
| `products`, `product_prices`, `product_barcodes`, `product_images`, `product_groups` | Каталог товаров. |
|
||||
| `counterparties` | Поставщики + покупатели (тип=Supplier/Individual/Legal). |
|
||||
| `stores`, `retail_points`, `units_of_measure`, `currencies`, `price_types`, `countries` | Справочники. |
|
||||
| `stocks`, `stock_movements` | Остатки + история движений. `stocks` — кеш `SUM(stock_movements)`. |
|
||||
| `supplies`, `supply_lines`, `enters`, `enter_lines`, `supplier_returns`, `supplier_return_lines` | Приходные документы. |
|
||||
| `losses`, `loss_lines`, `transfers`, `transfer_lines`, `inventory_docs`, `inventory_lines` | Внутренний учёт. |
|
||||
| `retail_sales`, `retail_sale_lines` | Чеки розницы + строки чека. Sprint 11: ОФД-снапшоты на `retail_sales` (FiscalNumber, FiscalQrCode, …). |
|
||||
| `demands`, `demand_lines` | Опт-отгрузки. |
|
||||
| `loyalty_programs`, `loyalty_cards`, `promotions` | Sprint 9. |
|
||||
| `pos_batch_acks` | Идемпотентность POS-синка (UNIQUE OrganizationId, IdempotencyKey). |
|
||||
| `org_audit_log` | JSONB-diff каждой mutate-операции tenant'а. |
|
||||
| `super_admin_audit_log` | Действия SuperAdmin'а (особенно в режиме «открыто как…»). |
|
||||
| `platform_settings` | Singleton: SMTP-конфиг платформы. |
|
||||
| `system_settings` | Singleton: per-tenant фичи (не путать с platform). |
|
||||
| `import_jobs` | История импортов MoySklad. |
|
||||
|
||||
OpenIddict хранит `openiddict_applications`/`authorizations`/`tokens`/`scopes`.
|
||||
Hangfire — `hangfire.*` (в своей схеме).
|
||||
|
||||
### Concurrency
|
||||
|
||||
`IVersionedEntity` сущности (Supply, RetailSale, Demand, Enter, Loss,
|
||||
Transfer, InventoryDoc, SupplierReturn) включают PG `xmin` через
|
||||
`UseXminAsConcurrencyToken()`. Параллельные апдейты одного документа
|
||||
получают `DbUpdateConcurrencyException`, контроллер возвращает 409.
|
||||
|
||||
Post-операции, изменяющие остаток, идут под `IsolationLevel.Serializable`
|
||||
(см. `RetailSalesController.Post`, `SuppliesController.Post`, …) —
|
||||
это защищает от race в `SUM(stock_movements)`-инварианте.
|
||||
|
||||
## Внешние интеграции
|
||||
|
||||
| Сервис | Где | Состояние |
|
||||
|---|---|---|
|
||||
| **MoySklad** | `Infrastructure/Integrations/MoySklad/` | Импорт товаров, контрагентов, остатков. Per-org token в `Organization.MoySkladToken`. |
|
||||
| **SMTP** | `Infrastructure/Email/MailKitEmailSender.cs` | Платформенный SMTP в `PlatformSettings` (SuperAdmin настраивает). Используется для invite, forgot-password, weekly-summary. |
|
||||
| **Telegram Bot** | `Api/Integrations/Telegram/` | Owner-сводка. Per-org `OwnerTelegramChatId`. Bot token в env. |
|
||||
| **ОФД (Webkassa / Kassa24 / ОФД-Соло)** | `Infrastructure/Fiscal/` | Sprint 11 scaffolding. Per-org провайдер + креды. |
|
||||
| **MinIO (S3)** | `Api/Storage/StorageBootstrap.cs` | Опциональный сторадж изображений. Если не настроен — `/uploads` volume на FS. |
|
||||
|
||||
## Тесты
|
||||
|
||||
- **Unit** (`tests/food-market.UnitTests/`) — xUnit + InMemory EF + чистые
|
||||
юниты на валидаторы, payload-builder'ы, MediatR-handler'ы.
|
||||
- **Integration** (`tests/food-market.IntegrationTests/`) — xUnit +
|
||||
Testcontainers Postgres (`postgres:16-alpine`). Полный API через
|
||||
`WebApplicationFactory<Program>`. Shared `ApiFactory` через
|
||||
`ApiCollection` (один контейнер на сессию xunit). Memory note:
|
||||
`test_suites_setup` — Ryuk выключен (TCN не тянет с docker-hub),
|
||||
rate-limiter eager-config через env-переменную.
|
||||
- **E2E** (`tests/e2e/`) — Playwright (TS) против stage
|
||||
`https://test.admin.food-market.kz`. Используется в verify-suite'ах
|
||||
по спринтам.
|
||||
- **Load** (`tests/load/`) — k6 (Sprint 12). См. `docs/performance-baseline.md`.
|
||||
|
||||
## Релиз-цикл
|
||||
|
||||
1. Локально: `dotnet build` + `dotnet test` + `pnpm build`.
|
||||
2. `git push origin main` (Forgejo на 127.0.0.1:3000 — primary remote,
|
||||
GitHub — mirror, memory `feedback_forgejo_primary`).
|
||||
3. `~/deploy-stage.sh` — docker build api+web → push в локальный registry
|
||||
`192.168.1.193:5001` → ssh на prod-vm → `docker compose pull && up -d`.
|
||||
4. Health check на `https://test.admin.food-market.kz/health/ready`.
|
||||
5. Verify на stage (Playwright или ручной чек).
|
||||
6. Prod-деплой — пока ручной (TBD, нужен план от user'а).
|
||||
|
||||
## Что ещё прочитать
|
||||
|
||||
- [MULTI-TENANCY.md](MULTI-TENANCY.md) — query filter, SuperAdmin override, подводные камни.
|
||||
- [RUNBOOK.md](RUNBOOK.md) — операционные процедуры.
|
||||
- [DEVELOPER-GUIDE.md](DEVELOPER-GUIDE.md) — как начать вкладываться в код.
|
||||
- [ofd-integration.md](ofd-integration.md) — ОФД-провайдеры.
|
||||
- [openapi.md](openapi.md) — генерация TS-клиента из Swagger.
|
||||
- [observability.md](observability.md) — Serilog + Prometheus.
|
||||
- [secrets.md](secrets.md) — управление секретами в stage/prod.
|
||||
- [stage-access.md](stage-access.md) — как попасть на stage-сервер.
|
||||
- [backup-restore.md](backup-restore.md) — бэкапы.
|
||||
439
docs/DEVELOPER-GUIDE.md
Normal file
439
docs/DEVELOPER-GUIDE.md
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
# 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) — где живут секреты.
|
||||
338
docs/MULTI-TENANCY.md
Normal file
338
docs/MULTI-TENANCY.md
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
# Multi-tenancy в food-market
|
||||
|
||||
Один процесс API, одна БД, много организаций (тенантов). Каждый запрос
|
||||
видит только данные своей организации. Изоляция держится на двух вещах:
|
||||
|
||||
1. **EF Core query filter** на каждой `ITenantEntity` (auto-инжектится
|
||||
в `WHERE` каждого SQL-запроса).
|
||||
2. **Stamping в SaveChanges** — добавляемые сущности получают
|
||||
`OrganizationId` из текущего `ITenantContext`.
|
||||
|
||||
`SuperAdmin` — отдельная роль с правом обходить фильтр (видеть/менять
|
||||
всё). Чтобы не получить «случайные изменения по всем оргам сразу»,
|
||||
есть строгий режим «открыть как…» с двумя ступенями (read-only +
|
||||
edit-mode с reason).
|
||||
|
||||
## Модель
|
||||
|
||||
### Базовые интерфейсы
|
||||
|
||||
```csharp
|
||||
// food-market.domain/Common/TenantEntity.cs
|
||||
|
||||
public interface ITenantEntity // обязательный orgId
|
||||
{
|
||||
Guid OrganizationId { get; set; }
|
||||
}
|
||||
|
||||
public abstract class TenantEntity : Entity, ITenantEntity
|
||||
{
|
||||
public Guid OrganizationId { get; set; }
|
||||
}
|
||||
|
||||
public interface IOptionalTenantEntity // системный справочник
|
||||
{
|
||||
Guid? OrganizationId { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### Когда использовать что
|
||||
|
||||
| Случай | База |
|
||||
|---|---|
|
||||
| Бизнес-данные тенанта: продукты, чеки, контрагенты, отчёты | `TenantEntity` (обязательный orgId) |
|
||||
| Системные справочники с возможностью per-tenant расширения: `UnitOfMeasure`, `ProductGroup`, `Country` | `IOptionalTenantEntity` (null = системная, оrgId = tenant'овская) |
|
||||
| Корневая сущность тенанта: `Organization` | `Entity` (сама не tenant-scoped) |
|
||||
| Платформенные настройки: `PlatformSettings` (SMTP), OpenIddict-клиенты | `Entity` (singletons / cross-tenant) |
|
||||
| Audit-логи SuperAdmin'а: `SuperAdminAuditLog` | `Entity` (есть optional `OrganizationId` для фильтра, но сама не tenant-scoped) |
|
||||
|
||||
## Tenant-контекст
|
||||
|
||||
`ITenantContext` (Application слой) — единственный источник правды о
|
||||
том, кто сейчас делает запрос:
|
||||
|
||||
```csharp
|
||||
// food-market.application/Common/Tenancy/ITenantContext.cs
|
||||
public interface ITenantContext
|
||||
{
|
||||
Guid? OrganizationId { get; } // null = SuperAdmin вне override, или нет JWT
|
||||
bool IsAuthenticated { get; }
|
||||
bool IsSuperAdmin { get; }
|
||||
bool IsTenantOverride { get; } // SuperAdmin в режиме «открыть как…»
|
||||
Guid? UserId { get; }
|
||||
}
|
||||
```
|
||||
|
||||
Реализация — `HttpContextTenantContext` (`food-market.api/Infrastructure/Tenancy/`).
|
||||
Источники данных в порядке приоритета:
|
||||
|
||||
1. **AsyncLocal-override** (`UseOverride(orgId, isSuper)`) — для
|
||||
background-tasks (Hangfire, импорт MoySklad, фоновые сидеры).
|
||||
Когда нет HttpContext, нужно явно задать tenant перед `DbContext`-вызовом.
|
||||
2. **HTTP-заголовок `X-Org-Override`** — режим «открыть как…»
|
||||
(только если у юзера роль SuperAdmin).
|
||||
3. **JWT claim `org_id`** — обычный tenant-юзер.
|
||||
|
||||
```csharp
|
||||
// background-job пример
|
||||
using (HttpContextTenantContext.UseOverride(orgId, isSuperAdmin: false))
|
||||
{
|
||||
// здесь _db применит фильтр на orgId
|
||||
var products = await _db.Products.ToListAsync();
|
||||
}
|
||||
```
|
||||
|
||||
## Query filter
|
||||
|
||||
`AppDbContext.OnModelCreating` после регистрации всех сущностей
|
||||
рефлексией обходит модель и вешает фильтр на каждую `ITenantEntity`:
|
||||
|
||||
```csharp
|
||||
// food-market.infrastructure/Persistence/AppDbContext.cs
|
||||
|
||||
private void ApplyTenantFilter<T>(ModelBuilder builder) where T : class, ITenantEntity
|
||||
{
|
||||
// SuperAdmin обходит фильтр ТОЛЬКО когда не в режиме «открыть как…».
|
||||
// В override-режиме (X-Org-Override header активен) он работает в
|
||||
// контексте конкретной орги — фильтр обязан применяться.
|
||||
builder.Entity<T>().HasQueryFilter(e =>
|
||||
(_tenant.IsSuperAdmin && !_tenant.IsTenantOverride)
|
||||
|| e.OrganizationId == _tenant.OrganizationId);
|
||||
}
|
||||
|
||||
private void ApplyOptionalTenantFilter<T>(ModelBuilder builder) where T : class, IOptionalTenantEntity
|
||||
{
|
||||
builder.Entity<T>().HasQueryFilter(e =>
|
||||
(_tenant.IsSuperAdmin && !_tenant.IsTenantOverride)
|
||||
|| e.OrganizationId == null
|
||||
|| e.OrganizationId == _tenant.OrganizationId);
|
||||
}
|
||||
```
|
||||
|
||||
Результат:
|
||||
|
||||
- Tenant-юзер: `WHERE OrganizationId = '<его orgId>'`.
|
||||
- SuperAdmin без override: фильтр не применяется (видит всё).
|
||||
- SuperAdmin с `X-Org-Override`: `WHERE OrganizationId = '<выбранная orgId>'`.
|
||||
- `IOptionalTenantEntity`: видит свои + системные (`IS NULL`).
|
||||
|
||||
## Stamping в SaveChanges
|
||||
|
||||
`AppDbContext.SaveChanges/Async` зовут `StampTenant()`, который
|
||||
проходит по `Added`-entries:
|
||||
|
||||
```csharp
|
||||
private void StampTenant()
|
||||
{
|
||||
foreach (var entry in ChangeTracker.Entries())
|
||||
{
|
||||
if (entry.State != EntityState.Added) continue;
|
||||
if (entry.Entity is ITenantEntity tenant && tenant.OrganizationId == Guid.Empty)
|
||||
{
|
||||
if (_tenant.OrganizationId.HasValue)
|
||||
tenant.OrganizationId = _tenant.OrganizationId.Value;
|
||||
}
|
||||
else if (entry.Entity is IOptionalTenantEntity opt && opt.OrganizationId is null)
|
||||
{
|
||||
// SuperAdmin без override: оставляем null (системная запись)
|
||||
// SuperAdmin с override / tenant-юзер: стампим текущий orgId
|
||||
if (_tenant.IsSuperAdmin && !_tenant.IsTenantOverride) { /* null */ }
|
||||
else if (_tenant.OrganizationId.HasValue)
|
||||
opt.OrganizationId = _tenant.OrganizationId.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Это значит: контроллер может писать `_db.Products.Add(product)` без
|
||||
явного `product.OrganizationId = ...` — stamping подставит сам.
|
||||
**НО**: если код явно выставил `OrganizationId` (например, чтобы создать
|
||||
запись для другой орги в Hangfire-job), stamping её не перетрёт.
|
||||
|
||||
## SuperAdmin override: режим «открыть как…»
|
||||
|
||||
Конкретный поток с фронта:
|
||||
|
||||
1. SuperAdmin заходит в «Системная консоль → Организации».
|
||||
2. Кликает «Открыть как…» на какой-то orgRow.
|
||||
3. Фронт начинает слать каждый запрос с заголовком
|
||||
`X-Org-Override: <orgId>`. Без этого хедера SuperAdmin видит «своё»
|
||||
(а у SuperAdmin'а часто нет своей орги, поэтому все списки пусты —
|
||||
у супер-админа в админке тренировочный режим).
|
||||
4. По умолчанию режим **read-only** (`ReadonlyOverrideMiddleware`):
|
||||
GET/HEAD/OPTIONS — пропускаются; PUT/POST/DELETE/PATCH — `403`.
|
||||
Исключение: `/api/super-admin/*` и `/connect/*` (refresh-token).
|
||||
5. Чтобы что-то поменять, фронт показывает «Войти в edit-mode»,
|
||||
запрашивает причину (≥ 10 символов), отправляет её в каждом запросе
|
||||
как `X-Org-Override-Reason: <текст>`. Тогда middleware пропускает
|
||||
мутации, а action-filter (`SuperAdminEditAuditFilter`) после успешного
|
||||
ответа пишет строку в `super_admin_audit_log` с reason'ом и
|
||||
запросом/ответом.
|
||||
6. Фронт ограничивает edit-mode 30 минутами (UI таймер).
|
||||
Сервер не следит за временем — это UX-конвенция, а аудит уже есть.
|
||||
|
||||
### ClaimsTransformer для tenant-ролей
|
||||
|
||||
Тонкость: SuperAdmin сам по себе не имеет ролей `Admin/Storekeeper/Cashier`
|
||||
(они — атрибуты `Employee` тенанта). Контроллер
|
||||
`[Authorize(Roles="Admin")]` отшил бы его 403 даже в edit-mode.
|
||||
|
||||
Решение: `SuperAdminOverrideClaimsTransformer` (`IClaimsTransformation`,
|
||||
вызывается на каждый authenticated request) — если есть `X-Org-Override`,
|
||||
динамически добавляет SuperAdmin'у `Admin/Storekeeper/Cashier` claim-роли
|
||||
**только на текущий запрос**. Записи в БД не трогает.
|
||||
|
||||
## RequiresPermission: тонкая авторизация
|
||||
|
||||
Для мутаций используется `[RequiresPermission("...")]` вместо
|
||||
`[Authorize(Roles="Admin,Storekeeper")]`. Атрибут резолвится через
|
||||
policy-механизм:
|
||||
|
||||
```
|
||||
[RequiresPermission("ProductsEdit")]
|
||||
→ Policy "perm:ProductsEdit"
|
||||
→ PermissionAuthorizationPolicyProvider создаёт PermissionRequirement
|
||||
→ PermissionAuthorizationHandler проверяет
|
||||
EmployeeRole.RolePermissions.ProductsEdit == true для текущего юзера
|
||||
```
|
||||
|
||||
`RolePermissions` — это POCO с булевыми полями (`ProductsView`,
|
||||
`ProductsEdit`, `RetailSalesOperate`, `RetailSalesRefund`, …).
|
||||
`Employee.EmployeeRoleId` указывает на конкретную роль, у каждой —
|
||||
свой набор флагов. SuperAdmin (с override) проходит всегда.
|
||||
|
||||
См. `food-market.api/Infrastructure/Authorization/`.
|
||||
|
||||
## Audit-trail
|
||||
|
||||
### `org_audit_log`
|
||||
|
||||
Каждая `Add/Update/Delete` в `AppDbContext` (через
|
||||
`OrgAuditInterceptor`) пишет JSONB-diff в `org_audit_log`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "...",
|
||||
"organizationId": "...",
|
||||
"userId": "...",
|
||||
"entityType": "Product",
|
||||
"entityId": "...",
|
||||
"action": "Update",
|
||||
"changesJson": { "before": {...}, "after": {...} },
|
||||
"createdAt": "..."
|
||||
}
|
||||
```
|
||||
|
||||
Фоновый Hangfire-job `prune-audit-log` (03:45) чистит записи старше
|
||||
180 дней.
|
||||
|
||||
Сидеры/миграции выставляют `_db.SkipAudit = true` чтобы не плодить
|
||||
бессмысленные строки.
|
||||
|
||||
### `super_admin_audit_log`
|
||||
|
||||
Только мутации SuperAdmin'а в edit-mode, плюс изменения платформенных
|
||||
настроек. Без TTL — храним всё.
|
||||
|
||||
## Известные подводные камни
|
||||
|
||||
### 1. `IgnoreQueryFilters()` — когда нужно знать
|
||||
|
||||
Тенант-фильтр применяется ко ВСЕМУ — в том числе там, где это «не надо».
|
||||
|
||||
- При логине: ищем `Organization` по `OrgId` из credentials → нужен
|
||||
`IgnoreQueryFilters()`, потому что фильтр требует OrganizationId,
|
||||
которого ещё нет в контексте.
|
||||
- При проверке «есть ли у юзера эта орг»: `_db.Organizations.IgnoreQueryFilters().AnyAsync(...)`.
|
||||
- При cross-tenant отчётах SuperAdmin'а без override-режима фильтр и так
|
||||
не применится, но при override — применится; чтобы получить
|
||||
cross-tenant данные в этом режиме (редко нужно), вызвать
|
||||
`IgnoreQueryFilters()` явно.
|
||||
|
||||
### 2. Stamping не работает, если orgId уже задан
|
||||
|
||||
Бэкенд-код в нескольких местах принимает `Guid OrganizationId` из payload
|
||||
(например, при импорте). Stamping проверяет `OrganizationId == Guid.Empty`
|
||||
и НЕ перетирает уже выставленное значение. Если кто-то по ошибке прислал
|
||||
чужой orgId в payload — он сохранится. Защита: явно валидировать
|
||||
`OrganizationId == _tenant.OrganizationId` в контроллере (или вообще не
|
||||
принимать поле из payload).
|
||||
|
||||
### 3. Background-jobs без HttpContext
|
||||
|
||||
`HangfireJobsConfigurator` регистрирует методы, исполняющиеся в фоне.
|
||||
`HttpContextAccessor.HttpContext` там null → `OrganizationId` тоже null →
|
||||
query filter возвращает только записи с `OrganizationId == null` (т.е.
|
||||
системные справочники), а tenant-запросы — пустоту.
|
||||
|
||||
Решение: внутри job, перед `DbContext`-вызовом, обернуть в
|
||||
`HttpContextTenantContext.UseOverride(orgId, isSuperAdmin: false)`.
|
||||
См. `OwnerDailySummaryJob`, `EmailNotificationJobs` — там есть пример.
|
||||
|
||||
### 4. SignalR за query-фильтром
|
||||
|
||||
`NotificationsHub` использует `IServiceProvider.GetRequiredService<AppDbContext>()`
|
||||
внутри `OnConnectedAsync` для добавления соединения в group. Если в
|
||||
connection нет JWT — нет org_id — нет группы → клиент не получит
|
||||
событий. Web-фронт прокидывает `?access_token=...` query (см. middleware
|
||||
в Program.cs), POS — `Authorization` header.
|
||||
|
||||
### 5. EF migrations и наследование от TenantEntity
|
||||
|
||||
При добавлении новой `TenantEntity` миграция должна включать
|
||||
`OrganizationId` колонку (uuid, NOT NULL) и индекс
|
||||
`(OrganizationId, …)` для фильтрации. **Эта колонка не появляется
|
||||
автоматически в snapshot-выводе `dotnet ef migrations add`** в этом
|
||||
проекте, потому что снапшот не синхронизируется с моделью (миграции
|
||||
пишутся руками, см. memory `feedback_ef_migrations`).
|
||||
|
||||
Проверка: после `Migrate()` тестовый запрос
|
||||
`SELECT column_name FROM information_schema.columns WHERE table_name='...'`
|
||||
должен показать `OrganizationId`.
|
||||
|
||||
### 6. `xmin` concurrency и параллельные посты
|
||||
|
||||
`UseXminAsConcurrencyToken()` на документах. Если два кассира одновременно
|
||||
постят один и тот же чек (что не должно случаться, но всё же) — второй
|
||||
получает `DbUpdateConcurrencyException`. Контроллер ловит и возвращает
|
||||
`409 Conflict` с сообщением «документ изменён в другой сессии, обновите
|
||||
страницу».
|
||||
|
||||
Stock-операции — отдельная история: `Serializable` транзакция блокирует
|
||||
параллельный пост на тех же товарах в том же storе. Серверу `PG40001`
|
||||
(serialization_failure) — контроллер не ретраит автоматически, кассир
|
||||
видит 409 «недостаточно остатка» (после ретрая по факту достаточно или
|
||||
нет).
|
||||
|
||||
### 7. Тестирование изоляции
|
||||
|
||||
`TenantIsolationTests` (integration) — обязательный смок: создаём 2
|
||||
организации, в одной — продукт; в другой делаем `GET /api/catalog/products`
|
||||
→ список пустой. На любую новую `ITenantEntity` добавлять такой тест.
|
||||
|
||||
### 8. Read-models / ad-hoc raw SQL
|
||||
|
||||
Если контроллер напишет raw SQL через `_db.Database.SqlQueryRaw(...)`,
|
||||
EF-фильтр НЕ применится. Это используется только для отчётов с тяжёлой
|
||||
агрегацией (`ProfitReportController`); там OrganizationId явно
|
||||
включается в `WHERE` и приходит из `_tenant.OrganizationId`.
|
||||
Правило: никаких raw SQL без явного `WHERE OrganizationId = @org` в
|
||||
середине запроса.
|
||||
|
||||
## Чеклист «как добавить новую tenant-сущность»
|
||||
|
||||
1. Унаследовать от `TenantEntity` (или реализовать `ITenantEntity`).
|
||||
2. Добавить EF Configuration в `food-market.infrastructure/Persistence/Configurations/`:
|
||||
- `b.ToTable("...");`
|
||||
- `b.HasIndex(x => new { x.OrganizationId, x.SomeField });` — индекс
|
||||
с OrganizationId первым полем (для скорости query filter'а).
|
||||
- Если есть уникальность в рамках org: `.IsUnique()` на индексе
|
||||
с OrganizationId первым полем.
|
||||
3. Создать миграцию руками в `Persistence/Migrations/`:
|
||||
- Атрибуты `[DbContext(typeof(AppDbContext))]` + `[Migration("YYYYMMDD…")]`.
|
||||
- В `Up()` — `CreateTable` с колонкой `OrganizationId uuid NOT NULL`.
|
||||
- Индекс на OrganizationId.
|
||||
4. Добавить `DbSet` в `AppDbContext`.
|
||||
5. Контроллер использует `_db.MyEntities.Where(...)` — query filter
|
||||
подключится автоматически. Stamping выставит `OrganizationId` в `Add()`.
|
||||
6. Интеграционный тест на изоляцию (см. `TenantIsolationTests`).
|
||||
344
docs/RUNBOOK.md
Normal file
344
docs/RUNBOOK.md
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
# Runbook — операционные процедуры food-market
|
||||
|
||||
Что делать, когда что-то идёт не так, или когда нужно сделать
|
||||
неавтоматическую операцию.
|
||||
|
||||
## Контактные точки
|
||||
|
||||
| Что | Где |
|
||||
|---|---|
|
||||
| Stage URL | https://test.admin.food-market.kz |
|
||||
| Prod URL | https://admin.food-market.kz (план, ещё не задеплоен) |
|
||||
| Stage VM | `nns@192.168.1.190` (через ssh, prod-vm в локалке) |
|
||||
| Dev VM (этот хост) | `nns@<this>` — здесь крутится локальный API/Postgres + локальный Forgejo + локальный Docker registry |
|
||||
| Forgejo (primary git) | http://127.0.0.1:3000/nns/food-market.git |
|
||||
| GitHub (mirror) | https://github.com/nurdotnet/food-market (только зеркало) |
|
||||
| Local Docker registry | `192.168.1.193:5001` (memory: `local_docker_registry`) |
|
||||
| Hangfire Dashboard (stage) | https://test.admin.food-market.kz/hangfire — только SuperAdmin |
|
||||
| Swagger (stage) | https://test.admin.food-market.kz/swagger |
|
||||
|
||||
## Health-чеки
|
||||
|
||||
| Endpoint | Что значит | Что делать при 503 |
|
||||
|---|---|---|
|
||||
| `GET /health` | Процесс отвечает | Контейнер живёт, проблема в обвязке (nginx/cert/DNS). |
|
||||
| `GET /health/live` | Процесс жив (без проверок) | То же. |
|
||||
| `GET /health/ready` | БД отвечает + миграции применены | См. ниже «Health/ready упал». |
|
||||
| `GET /metrics` | Prometheus exposition | Если 404 — приложение не стартануло. |
|
||||
|
||||
### `/health/ready` упал
|
||||
|
||||
1. `ssh nns@192.168.1.190 'docker logs --tail 100 food-market-stage-api'` —
|
||||
стек ошибки на старте.
|
||||
2. Типичные причины:
|
||||
- **Миграция упала**: ищем `Failed executing DbCommand` / `relation
|
||||
"..." already exists`. Решение: миграция конфликтует со снапшотом
|
||||
БД. Возможно её надо переписать с `IF NOT EXISTS` (см.
|
||||
`Phase6e_RetailSaleReturns.cs` как пример «defensive migration»).
|
||||
- **OpenIddict cert pass mismatch**: переменная
|
||||
`OpenIddict__CertPassword` в docker-compose env'е не совпадает с
|
||||
паролем PFX-файла → `CryptographicException: PKCS12 password incorrect`.
|
||||
- **Connection refused**: Postgres контейнер не успел подняться.
|
||||
`depends_on.condition: service_healthy` должно это покрывать,
|
||||
но если healthcheck не успел — `docker compose restart api`.
|
||||
3. Если фикс требует кода — `~/deploy-stage.sh` после правки.
|
||||
|
||||
## Деплой на stage
|
||||
|
||||
```bash
|
||||
~/deploy-stage.sh
|
||||
```
|
||||
|
||||
Скрипт делает:
|
||||
1. `docker build` api и web с локальным registry в качестве кеша.
|
||||
2. `docker push` обоих образов в `192.168.1.193:5001`.
|
||||
3. `ssh nns@192.168.1.190` → `docker compose -p food-market-stage pull api web` → `up -d --force-recreate`.
|
||||
4. Ждёт `https://test.admin.food-market.kz/health/ready` до 30с.
|
||||
|
||||
**Важно**: проект `docker compose` называется `food-market-stage`
|
||||
(флаг `-p food-market-stage`). См. инцидент ниже про project name.
|
||||
|
||||
## Бэкап и восстановление
|
||||
|
||||
### Расписание
|
||||
|
||||
systemd-таймер `food-market-backup.timer` (см. `deploy/`) запускается
|
||||
**каждый день в 03:00 локального времени** prod-vm. Запускается через
|
||||
`OnCalendar=*-*-* 03:00:00` + `Persistent=true` (догоняет пропущенные
|
||||
если сервер был выключен).
|
||||
|
||||
Скрипт `food-market-backup.sh`:
|
||||
- `pg_dump -Fc` из контейнера `food-market-postgres` → `db-<TS>.dump`.
|
||||
- `tar czf` каталога `/opt/food-market-data/uploads` → `uploads-<TS>.tgz`.
|
||||
- Удаляет файлы старше 30 дней (`FM_BACKUP_RETENTION_DAYS`).
|
||||
|
||||
Папка: `/opt/food-market-data/backups/`.
|
||||
|
||||
### Ручной бэкап
|
||||
|
||||
```bash
|
||||
ssh nns@192.168.1.190 'sudo /opt/food-market/deploy/food-market-backup.sh'
|
||||
```
|
||||
|
||||
Или из репо разработчика:
|
||||
```bash
|
||||
deploy/backup.sh --remote 192.168.1.190:5434 # PG в Docker exposed на 5434
|
||||
```
|
||||
|
||||
### Восстановление БД из дампа
|
||||
|
||||
> ⚠️ Перезаписывает данные. Сначала остановить API.
|
||||
|
||||
```bash
|
||||
ssh nns@192.168.1.190
|
||||
cd /opt/food-market
|
||||
|
||||
# 1. Остановить API/Web, оставить Postgres
|
||||
docker compose -p food-market-stage stop api web
|
||||
|
||||
# 2. Применить дамп
|
||||
DUMP=/opt/food-market-data/backups/db-YYYYMMDD-HHMMSS.dump
|
||||
docker exec -i food-market-stage-postgres \
|
||||
pg_restore -U food_market -d food_market \
|
||||
--clean --if-exists --no-owner --no-privileges \
|
||||
< "$DUMP"
|
||||
|
||||
# 3. Поднять API обратно — миграции применятся автоматически (idempotent)
|
||||
docker compose -p food-market-stage up -d api web
|
||||
|
||||
# 4. Проверить
|
||||
curl https://test.admin.food-market.kz/health/ready
|
||||
```
|
||||
|
||||
### Восстановление uploads
|
||||
|
||||
```bash
|
||||
ssh nns@192.168.1.190
|
||||
cd /opt/food-market-data
|
||||
sudo tar xzf backups/uploads-YYYYMMDD-HHMMSS.tgz
|
||||
# Содержимое восстанавливается в текущий каталог (uploads/...)
|
||||
```
|
||||
|
||||
### Полный disaster-recovery (новый сервер)
|
||||
|
||||
1. Поднять Docker, склонировать репо в `/opt/food-market`.
|
||||
2. Скопировать бэкапы в `/opt/food-market-data/backups/`.
|
||||
3. Запустить пустой стек:
|
||||
```bash
|
||||
cd /opt/food-market/deploy
|
||||
docker compose -p food-market-stage up -d postgres
|
||||
docker compose -p food-market-stage exec postgres pg_isready
|
||||
```
|
||||
4. Применить дамп (см. выше).
|
||||
5. Восстановить uploads.
|
||||
6. Запустить остальное: `docker compose -p food-market-stage up -d`.
|
||||
7. Поднять nginx + сертификат (см. `docs/stage-access.md`).
|
||||
8. Включить таймер бэкапов:
|
||||
```bash
|
||||
sudo cp deploy/food-market-backup.{service,timer} /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now food-market-backup.timer
|
||||
```
|
||||
|
||||
## Перенос на другой сервер
|
||||
|
||||
1. На старом — снять свежий бэкап вручную.
|
||||
2. На новом — поднять Docker, склонировать репо, восстановить (см. выше).
|
||||
3. Обновить DNS A-запись `admin.food-market.kz` на новый IP.
|
||||
4. Дождаться распространения DNS (TTL).
|
||||
5. Старый сервер — выключить через 24 часа (для гарантии).
|
||||
|
||||
## Смена SDK-версии
|
||||
|
||||
> ⚠️ `global.json` фиксирует `8.0.417` с `rollForward: latestFeature`.
|
||||
> Менять только когда вышел новый patch и Microsoft анонсировал
|
||||
> EOL текущего. memory: НЕ переключать systemwide postgres версию.
|
||||
|
||||
1. На dev-машине: `dotnet --list-sdks` — проверить что новая версия
|
||||
установлена.
|
||||
2. Обновить `global.json` → новый patch.
|
||||
3. `dotnet build` + `dotnet test`.
|
||||
4. `deploy/Dockerfile.api` — обновить `FROM mirror/dotnet-sdk:X.Y`
|
||||
(если тэг изменился).
|
||||
5. `~/deploy-stage.sh` — задеплоить, проверить `/health/ready`.
|
||||
6. Verify-suite (Playwright или вручную smoke).
|
||||
7. Только после этого — менять на prod-машине.
|
||||
|
||||
## Логи
|
||||
|
||||
| Где | Что |
|
||||
|---|---|
|
||||
| `docker logs food-market-stage-api` (по контейнеру) | Console JSON Serilog. |
|
||||
| `/opt/food-market-data/api-logs/` (Docker volume) | Файлы Serilog rolling. |
|
||||
| `journalctl -u food-market-backup.service --no-pager` | Логи бэкапа. |
|
||||
| Hangfire Dashboard `/hangfire` | Состояние фоновых джобов, истории, ошибки. |
|
||||
|
||||
Формат JSON-логов — структурированный, каждая запись содержит
|
||||
`CorrelationId`, `OrgId`, `UserId` (через `LogEnrichmentMiddleware`).
|
||||
Поиск по `CorrelationId` восстанавливает полный trace запроса.
|
||||
|
||||
## Метрики
|
||||
|
||||
Prometheus scrape: `GET /metrics` (без auth). Локально в проекте нет
|
||||
prometheus-сервера — на stage его тоже пока нет; план — поднять
|
||||
prometheus + grafana отдельным compose'ом и proxy через nginx.
|
||||
|
||||
Ключевые метрики (`food-market.api/Infrastructure/Observability/AppMetrics.cs`):
|
||||
|
||||
- `food_market_posted_total{document_type="..."}` — счётчик post'ов.
|
||||
- `food_market_unposted_total{document_type="..."}` — счётчик unpost'ов.
|
||||
- `food_market_db_query_duration_seconds_*` — гистограмма EF-запросов
|
||||
(interceptor).
|
||||
- Стандартные prometheus-net: `http_requests_received_total`,
|
||||
`http_request_duration_seconds`, `dotnet_collection_count_total`,
|
||||
etc.
|
||||
|
||||
## Известные инциденты
|
||||
|
||||
### Инцидент 1: docker-compose project name
|
||||
|
||||
**Симптом** (наблюдался при первой миграции на новый stage):
|
||||
- `docker compose pull && up -d` создавали контейнеры с именами
|
||||
`deploy-api-1` вместо ожидаемых `food-market-stage-api`.
|
||||
- Healthcheck'и `depends_on` отрабатывали по новым именам, но nginx
|
||||
configurated на старые — 502 Bad Gateway.
|
||||
|
||||
**Причина**: `docker compose` берёт project name из имени каталога,
|
||||
если не указан `-p`. Каталог `deploy/` → project=`deploy` → контейнеры
|
||||
с префиксом `deploy-`. Старые контейнеры с префиксом `food-market-stage-`
|
||||
оставались стопнутыми, новые поднялись параллельно (Docker не считает
|
||||
их дубликатами потому что имена разные).
|
||||
|
||||
**Решение**: всегда передавать `-p food-market-stage`. Сделано в
|
||||
`~/deploy-stage.sh`. На prod ставить аналогичный wrapper-скрипт,
|
||||
не запускать `docker compose` голым из `/opt/food-market/deploy`.
|
||||
|
||||
**Превенция**: в будущем — `COMPOSE_PROJECT_NAME=food-market-stage`
|
||||
в `/etc/environment` на серверах, чтобы голый `docker compose` тоже
|
||||
не промахивался.
|
||||
|
||||
### Инцидент 2: GHCR network flakiness
|
||||
|
||||
**Симптом**: docker push/pull в `ghcr.io` периодически зависает на
|
||||
2-5 минут или падает по TCP-таймауту.
|
||||
|
||||
**Причина**: исходящая сеть с dev-vm к github.com нестабильна
|
||||
(memory: `network_github_flaky`).
|
||||
|
||||
**Решение**: используем **локальный Docker registry** на
|
||||
`192.168.1.193:5001` как primary, ghcr только как mirror (для
|
||||
external CI/CD когда понадобится). Stage compose тянет с локального
|
||||
(`REGISTRY=192.168.1.193:5001`). См. memory `local_docker_registry`.
|
||||
|
||||
### Инцидент 3: OpenIddict cert rotation
|
||||
|
||||
**Симптом**: после `docker compose down -v` (с удалением volume
|
||||
`api-data`) OpenIddict не может расшифровать существующие refresh-токены
|
||||
→ все пользователи разлогинены.
|
||||
|
||||
**Причина**: keys из `App_Data/oidc-keys/` пропали вместе с volume.
|
||||
|
||||
**Решение / превенция**:
|
||||
- НИКОГДА не делать `down -v` на stage/prod без явного намерения.
|
||||
- Хранить `App_Data` volume отдельно: `volumes: api-data:` с
|
||||
`external: true` (план).
|
||||
- Бэкап `App_Data` вместе с БД (TODO: добавить в `food-market-backup.sh`).
|
||||
|
||||
### Инцидент 4: rate-limiter eager-config
|
||||
|
||||
**Симптом** (в integration-тестах): тесты падают с `429 Too Many Requests`
|
||||
после ~5 signup'ов.
|
||||
|
||||
**Причина**: `RateLimiting:Enabled=true` (default) читается ЭАГЕРНО при
|
||||
регистрации сервисов; `ConfigureAppConfiguration` в `WebApplicationFactory`
|
||||
применяется позже и не успевает override'нуть.
|
||||
|
||||
**Решение**: в integration-тестах ставим `RateLimiting__Enabled=false`
|
||||
через переменную окружения **до** создания factory. Сделано в
|
||||
`ApiFactory` static-конструкторе. Memory: `test_suites_setup`.
|
||||
|
||||
### Инцидент 5: Telegram chat-id привязка
|
||||
|
||||
**Симптом**: владелец org вводит chat_id, сервер тестирует отправку →
|
||||
`403 Forbidden` от Telegram API.
|
||||
|
||||
**Причина**: пользователь не отправил `/start` боту перед привязкой,
|
||||
бот не может писать первым.
|
||||
|
||||
**Решение / превенция**: UI показывает инструкцию «1. Откройте бота → `/start`.
|
||||
2. Получите chat_id у `@userinfobot`. 3. Введите.» Это идёт сверху на
|
||||
странице привязки. Бэкенд возвращает ошибку с понятным текстом.
|
||||
|
||||
### Инцидент 6: Identity password policy
|
||||
|
||||
**Симптом**: signup-форма принимает пароль `12345`, потом
|
||||
`/connect/token` отшивает «Invalid credentials» — потому что Identity
|
||||
сам не разрешил создать пользователя с таким паролем, но контроллер
|
||||
проглотил ошибку.
|
||||
|
||||
**Превенция**: контроллер `AuthController.Signup` теперь возвращает
|
||||
`IdentityResult.Errors` массивом → фронт показывает причину.
|
||||
|
||||
## Troubleshooting на стороне БД
|
||||
|
||||
### Большой `org_audit_log`
|
||||
|
||||
`prune-audit-log` каждый день чистит >180 дней; если каталог-tenant
|
||||
делал массовый импорт (10к товаров за раз), таблица может вырасти на
|
||||
порядок. Проверка:
|
||||
```sql
|
||||
SELECT pg_size_pretty(pg_total_relation_size('org_audit_log'));
|
||||
SELECT count(*) FROM org_audit_log WHERE created_at < now() - interval '30 days';
|
||||
```
|
||||
|
||||
Ручная чистка:
|
||||
```sql
|
||||
DELETE FROM org_audit_log WHERE created_at < now() - interval '90 days';
|
||||
VACUUM ANALYZE org_audit_log;
|
||||
```
|
||||
|
||||
### Stock-агрегат расходится с движениями
|
||||
|
||||
Инвариант: `stocks.quantity = SUM(stock_movements.quantity)` per
|
||||
`(product_id, store_id)`. Если разошёлся (баг где-то не вызвали
|
||||
`IStockService.ApplyMovementAsync`):
|
||||
|
||||
```sql
|
||||
-- найти расхождения
|
||||
SELECT s.product_id, s.store_id, s.quantity AS cached,
|
||||
COALESCE(SUM(m.quantity), 0) AS actual
|
||||
FROM stocks s
|
||||
LEFT JOIN stock_movements m
|
||||
ON m.product_id = s.product_id AND m.store_id = s.store_id
|
||||
GROUP BY s.product_id, s.store_id, s.quantity
|
||||
HAVING s.quantity <> COALESCE(SUM(m.quantity), 0);
|
||||
|
||||
-- пересчитать всё (под maintenance window!)
|
||||
UPDATE stocks s SET quantity = COALESCE((
|
||||
SELECT SUM(quantity) FROM stock_movements m
|
||||
WHERE m.product_id = s.product_id AND m.store_id = s.store_id
|
||||
), 0);
|
||||
```
|
||||
|
||||
### `__EFMigrationsHistory` рассинхрон
|
||||
|
||||
Бывает после ручной правки миграции после её применения.
|
||||
```sql
|
||||
SELECT * FROM "__EFMigrationsHistory" ORDER BY 1 DESC LIMIT 5;
|
||||
```
|
||||
Если в коде есть миграция, которой нет в таблице — `db.Database.Migrate()`
|
||||
попытается её применить (что обычно и нужно). Если в таблице есть запись,
|
||||
а файла нет — обратное направление (миграция была удалена) — `Migrate()`
|
||||
не упадёт, но фокус с EF Tools перестанет работать, см. memory
|
||||
`feedback_ef_migrations`.
|
||||
|
||||
## Что НЕ делать
|
||||
|
||||
- НЕ менять `global.json` без явного решения (CLAUDE.md).
|
||||
- НЕ переключать systemwide postgres версию через brew (поломает
|
||||
смежные проекты в `~/Documents/devprojects/`).
|
||||
- НЕ запускать `docker compose down -v` на stage/prod (потеря volume).
|
||||
- НЕ делать миграции через `dotnet ef migrations add` — снапшот в репо
|
||||
не синхронный с моделью, генератор выдаст ерунду. Пишем руками.
|
||||
- НЕ редактировать тот же файл одновременно с Mac-Claude (memory:
|
||||
`feedback_serialize_edits`).
|
||||
178
docs/performance-baseline.md
Normal file
178
docs/performance-baseline.md
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
# Performance baseline — food-market API
|
||||
|
||||
Дата прогона: **2026-06-07**. Прогон против stage:
|
||||
`https://test.admin.food-market.kz`. Инструмент — k6 v0.55.0.
|
||||
|
||||
Сетап stage'а на момент замеров:
|
||||
- 1 контейнер `food-market-stage-api` (Kestrel, .NET 8).
|
||||
- 1 контейнер `food-market-stage-postgres` (Postgres 16, дефолтные настройки).
|
||||
- Nginx-фронт на dev-vm `192.168.1.190`, dev-машина k6-генератора в той
|
||||
же локалке (RTT ~5-20мс).
|
||||
|
||||
Все цифры — **с одного клиента**, без воспроизведения пиковой нагрузки
|
||||
из ЦА продакшна. Это baseline для регрессий, не SLA.
|
||||
|
||||
## TL;DR — что работает, что нет
|
||||
|
||||
| Операция | Здоровый сценарий | Предел до деградации | Узкое место |
|
||||
|---|---|---|---|
|
||||
| **GET /api/reports/sales** (1500 чеков в орге) | p95 50-115ms до 5 VU | После 5 VU непредсказуемо (см. ниже) | PG aggregation / connection pool |
|
||||
| **POST /api/auth/signup** | p95 446ms при 50 RPM | 100 RPM с одного IP → 39% 429 | IP rate-limit (60/мин, by design) |
|
||||
| **POST /api/sales/retail + /post** (sequential) | p95 71ms, 17 sales/sec | VU > 1 на одном tenant'е → unique-violation race | `GenerateNumberAsync` race condition |
|
||||
|
||||
## Прогон 1: signup-burst
|
||||
|
||||
`tests/load/signup-burst.js` — 50/100 регистраций/мин с одного IP, 60с.
|
||||
|
||||
### 50 RPM (под IP-лимитом 60/мин)
|
||||
|
||||
| Метрика | Значение |
|
||||
|---|---|
|
||||
| Iterations | 51 (за 60с) |
|
||||
| http_req_duration p50 | 391ms |
|
||||
| http_req_duration p90 | 425ms |
|
||||
| http_req_duration p95 | 446ms |
|
||||
| http_req_duration p99 | ~1.37s (один outlier) |
|
||||
| signup_rate_limited | 0% |
|
||||
| Failures | 0 |
|
||||
|
||||
Прогон чистый. Signup на stage'е укладывается в ~400-450ms p95.
|
||||
|
||||
### 100 RPM (превышение IP-лимита)
|
||||
|
||||
| Метрика | Значение |
|
||||
|---|---|
|
||||
| Iterations | 101 |
|
||||
| 2xx (успешные) | 62 |
|
||||
| 429 (rate-limited) | 39 (38.6%) |
|
||||
| http_req_duration p95 (2xx-only) | 437ms |
|
||||
|
||||
429-ответы возвращаются за единицы миллисекунд (`http_req_duration`
|
||||
total p95 показывает 436ms потому что включает и 429 — лимитер очень
|
||||
быстрый). Поведение by design (см. `AuthRateLimiterExtensions`),
|
||||
указывает что защита работает.
|
||||
|
||||
## Прогон 2: retail-sales-parallel
|
||||
|
||||
`tests/load/retail-sales-parallel.js` — на одном tenant'е N параллельных
|
||||
кассиров (VU) проводят чеки. Тест создаёт draft (`POST /api/sales/retail`)
|
||||
и сразу проводит (`POST /api/sales/retail/{id}/post`).
|
||||
|
||||
### VU=1 (sequential baseline) — 200 итераций
|
||||
|
||||
| Метрика | Значение |
|
||||
|---|---|
|
||||
| Iterations | 200/200 (100%) |
|
||||
| Throughput | **17 sales/sec** |
|
||||
| sale_draft_ms p50 | 25ms |
|
||||
| sale_draft_ms p95 | 37ms |
|
||||
| sale_post_ms p50 | 26ms |
|
||||
| sale_post_ms p95 | 35ms |
|
||||
| sale_total_ms p95 | **71ms** |
|
||||
| sale_total_ms p99 | ~90ms |
|
||||
| post_4xx | 0% |
|
||||
|
||||
Идеальная картинка: 17 sales/sec на single-thread, латенция стабильная.
|
||||
|
||||
### VU=5 (параллельные кассиры) — 200 итераций
|
||||
|
||||
| Метрика | Значение |
|
||||
|---|---|
|
||||
| Iterations | 200/200 (driver), но успешных только 94 |
|
||||
| post_4xx | **53% 🔴** |
|
||||
| sale_draft_ms p95 (включая failed) | 151ms |
|
||||
| sale_total_ms p95 (только успешные) | 185ms |
|
||||
|
||||
**Узкое место найдено: race в `GenerateNumberAsync`.**
|
||||
|
||||
`RetailSalesController.GenerateNumberAsync` строит next-number чтением
|
||||
последней `Number` для tenant'а и +1. Под параллельными VU несколько
|
||||
запросов читают одно и то же `lastNumber`, генерируют одинаковый
|
||||
`ПР-2026-000XXX`, на INSERT падают на unique-index
|
||||
`IX_retail_sales_OrganizationId_Number`. `SaveOrFkErrorAsync` ловит
|
||||
только 23503 (FK violation), не 23505 (unique violation) — поэтому
|
||||
до клиента долетает 500 (или 400 от EF middleware).
|
||||
|
||||
**Что делать (отдельная задача, не в Sprint 12)**: завести
|
||||
`organization_counters` (singleton-row per tenant), увеличивать счётчик
|
||||
через `UPDATE … RETURNING value` в той же транзакции. Альтернатива —
|
||||
ловить 23505 и ретраить с +1 в цикле. Третий вариант — использовать
|
||||
PG sequence per tenant (более сложно, но самое чистое).
|
||||
|
||||
## Прогон 3: sales-report-heavy
|
||||
|
||||
`tests/load/sales-report-heavy.js` — за 20-30 секунд VU дёргают
|
||||
`GET /api/reports/sales?dateFrom=...&dateTo=...&groupBy=day`,
|
||||
`/api/reports/abc?...` и `/api/dashboard/top-products?limit=5`.
|
||||
|
||||
Tenant с **1500 проведённых чеков, 5535 stock_movements, 200 товаров**
|
||||
(посеян через `POST /api/admin/seed-demo?years=1` — это YearDemoSeeder).
|
||||
|
||||
| VU | Throughput (iter/s) | sales p95 | abc p95 | non_2xx | Заметки |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | 6.7 | 54ms | 51ms | 0% | Чистый baseline. |
|
||||
| 2 | 17.5 | 60ms | 54ms | 0% | Linear, ОК. |
|
||||
| 3 | 23.5 | 67ms | 63ms | 0% | Linear, ОК. |
|
||||
| 4 | 25.4 | 81ms | 78ms | 0% | Незначительная деградация. |
|
||||
| 5 | 24.0 | 114ms | 108ms | 0% | Деградация заметнее, throughput плато. |
|
||||
| 5* | 8.7 | 3 800ms 🔴 | 3 500ms 🔴 | 0% | Аномалия одного прогона (см. ниже). |
|
||||
|
||||
`*` Первый прогон VU=5 показал p95 ~3.8с на отчёт. Повторный прогон —
|
||||
обычные 114ms p95. Скорее всего совпало с autovacuum'ом
|
||||
`stock_movements` (5535 строк, частые обновления при seed'е). Это
|
||||
напоминает: в production нужны:
|
||||
- Мониторинг p95 отчётов в Prometheus + алерт на отклонение от baseline.
|
||||
- Тюнинг `autovacuum_*` для `stock_movements` (или явный
|
||||
`VACUUM ANALYZE` после массовых seed'ов).
|
||||
|
||||
### Что НЕ протестировано (требует входа от user)
|
||||
|
||||
- **10 000 чеков на одного tenant'а** — пользователь просил «отчёт Sales
|
||||
с 10 000 продажами». YearDemoSeeder делает максимум 1500 (это сезонный
|
||||
год для одного магазина); чтобы получить 10к — нужно либо допилить
|
||||
seeder на «10 лет» / «10 магазинов», либо запустить несколько
|
||||
параллельных seed'ов под отдельными tenant'ами и тестировать
|
||||
cross-tenant. Пока обозначено как TODO для будущего спринта.
|
||||
- **Реальная нагрузка из ЦА** — k6 запускается из локалки, RTT
|
||||
5-20мс. Реальный пользователь из Алматы добавит 30-80мс к
|
||||
каждому запросу. Считать SLA с учётом этого.
|
||||
- **POS-синк** (`POST /api/pos/v1/batch`) — отдельный сценарий, потому
|
||||
что требует серии чеков с идемпотентным ключом и подходящих refs.
|
||||
TODO: `pos-sync.js`.
|
||||
|
||||
## Сводка: что нужно поправить
|
||||
|
||||
| Приоритет | Что | Где |
|
||||
|---|---|---|
|
||||
| 🔴 P0 | Race в `GenerateNumberAsync` | `RetailSalesController.cs:957` |
|
||||
| 🟡 P1 | Тот же подход в `SuppliesController/DemandsController/...` | Везде где есть `GenerateNumberAsync` |
|
||||
| 🟡 P1 | `SaveOrFkErrorAsync` не ловит 23505 (unique violation) | `RetailSalesController.cs:418` |
|
||||
| 🟢 P2 | Tune autovacuum для `stock_movements` | PG config / `ALTER TABLE` |
|
||||
| 🟢 P2 | Прометей-алерт на p95 отчёта | observability |
|
||||
| 🟢 P2 | k6 для POS-синка с idempotency | `tests/load/pos-sync.js` |
|
||||
|
||||
## Воспроизведение
|
||||
|
||||
```bash
|
||||
# k6 v0.55+ должен быть в PATH (см. tests/load/README.md)
|
||||
cd tests/load
|
||||
|
||||
# 1. Signup-burst (60с, 50 RPM)
|
||||
BASE_URL=https://test.admin.food-market.kz TARGET_RPM=50 \
|
||||
k6 run signup-burst.js
|
||||
|
||||
# 2. Sales sequential baseline
|
||||
BASE_URL=https://test.admin.food-market.kz \
|
||||
DURATION_S=120 TARGET_ITERS=200 VUS=1 \
|
||||
k6 run retail-sales-parallel.js
|
||||
|
||||
# 3. Reports на свежем tenant'е (нужны creds от signup + year-demo seed)
|
||||
SLUG="loadbase-$(date +%s)"
|
||||
EMAIL="$SLUG@example.kz"
|
||||
curl -sX POST $BASE_URL/api/auth/signup -H 'Content-Type: application/json' \
|
||||
-d "{\"email\":\"$EMAIL\",\"password\":\"Passw0rd!\",\"organizationName\":\"LoadOrg\",\"phone\":\"+77001234567\"}"
|
||||
TOKEN=$(curl -sX POST $BASE_URL/connect/token -d "grant_type=password&username=$EMAIL&password=Passw0rd!&client_id=food-market-web&scope=openid profile email roles api offline_access" | jq -r .access_token)
|
||||
curl -sX POST -H "Authorization: Bearer $TOKEN" "$BASE_URL/api/admin/seed-demo?years=1"
|
||||
|
||||
EMAIL=$EMAIL PASSWORD=Passw0rd! VUS=3 k6 run sales-report-heavy.js
|
||||
```
|
||||
131
docs/sprint12-progress.md
Normal file
131
docs/sprint12-progress.md
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
# Sprint 12 — документация, runbook, нагрузочное тестирование
|
||||
|
||||
Цель: переложить «то что знаю только я и комментарии в коде» в
|
||||
читаемые документы для следующего разработчика, замерить реальную
|
||||
производительность под нагрузкой, и закрыть автоматическую верификацию
|
||||
stage-стэйджа на каждый push.
|
||||
|
||||
Старт: 2026-06-07. Исполнитель: Claude Opus 4.7.
|
||||
|
||||
Это **последний автономно-безопасный спринт**. Дальше нужны входы от
|
||||
user'а: реальные ОФД-ApiKey, MoySklad webhook-token'ы, Windows-машина
|
||||
для POS WPF, прод-деплой план, казахские переводы, реальный SMTP-провайдер.
|
||||
|
||||
## Принципы
|
||||
|
||||
- Документация — для человека, не «AI-портянка». Конкретные пути, имена
|
||||
типов, причины решений. Без воды и эмоций.
|
||||
- k6 — реальные числа. Если p95 высокий — пишем как есть.
|
||||
- НЕ трогать: `global.json`, прод-стек, POS WPF.
|
||||
|
||||
## Чек-лист
|
||||
|
||||
- [x] **1. docs/ARCHITECTURE.md** — карта слоёв, модулей, потоков
|
||||
signup→bootstrap→операции. Реальные имена типов и путей, не маркетинг.
|
||||
- [x] **2. docs/MULTI-TENANCY.md** — `ITenantEntity` + reflection
|
||||
query-filter, stamping в SaveChanges, SuperAdmin override (read-only +
|
||||
edit-mode с reason), 8 подводных камней (IgnoreQueryFilters, фоновые
|
||||
jobs без HttpContext, raw SQL, и т.д.).
|
||||
- [x] **3. docs/RUNBOOK.md** — health-чеки, backup/restore (включая
|
||||
disaster-recovery), смена SDK, перенос на новый сервер, **6 описанных
|
||||
инцидентов** (включая docker-compose project name из ТЗ),
|
||||
troubleshooting БД (stock-агрегат расхождения, audit-log
|
||||
размер, EFMigrationsHistory).
|
||||
- [x] **4. docs/DEVELOPER-GUIDE.md** — локальный setup, запуск тестов,
|
||||
гочи integration-тестов (Ryuk, rate-limiter eager-config, один
|
||||
ApiFactory), полные паттерны: добавить controller с permission +
|
||||
добавить tenant-сущность с RowVersion + 5 шагов миграции, валидация
|
||||
(DataAnnotations / FluentValidation / бизнес), structured-логирование.
|
||||
- [x] **5. k6 нагрузочный тест** — `tests/load/` + 3 скрипта
|
||||
(signup-burst, retail-sales-parallel, sales-report-heavy) +
|
||||
`docs/performance-baseline.md` с **реальными цифрами** на stage'е.
|
||||
Главное найденное: race в `GenerateNumberAsync` при VU > 1 на одном
|
||||
tenant'е (unique-violation 23505 не ловится → 500). Прогон зарегистрирован
|
||||
как P0 для следующего рефакторинга.
|
||||
- [x] **6. CI workflow `.forgejo/workflows/stage-verify.yml`** —
|
||||
`on workflow_run` после `Docker API`/`Docker Web`, ждёт
|
||||
`/health/ready` и запускает `tests/stage-smoke.sh` (~7с,
|
||||
full-cycle smoke: signup → multi-tenant isolation → supply.post →
|
||||
retail-sale.post → stock check). Telegram-нотификация по
|
||||
успеху/падению.
|
||||
|
||||
## Журнал
|
||||
|
||||
### 2026-06-07 старт
|
||||
Sprint 11 закрыт (7/7 ✓). Поехали по docs-чек-листу.
|
||||
|
||||
### 2026-06-07 п.1–п.4 (документация)
|
||||
Прочитал реальный код: `Program.cs` composition root, `AppDbContext`
|
||||
reflection-фильтры, `HttpContextTenantContext` с AsyncLocal-override,
|
||||
`SuperAdminOverrideClaimsTransformer` + `ReadonlyOverrideMiddleware`,
|
||||
`RequiresPermissionAttribute` + policy-handler, `HangfireJobsConfigurator`
|
||||
recurring jobs, deploy/Dockerfile + docker-compose, backup-скрипт +
|
||||
systemd-timer.
|
||||
|
||||
Написал 4 документа на основе этого:
|
||||
- `ARCHITECTURE.md` (372 строки) — слои + модули + composition root +
|
||||
поток signup→post с детальным трассировщиком ASP.NET pipeline.
|
||||
- `MULTI-TENANCY.md` (256 строк) — query-filter, stamping,
|
||||
SuperAdmin override, 8 подводных камней + чеклист «как добавить
|
||||
tenant-сущность».
|
||||
- `RUNBOOK.md` (337 строк) — health-чеки, backup/restore с примером,
|
||||
смена SDK, disaster-recovery, 6 инцидентов, БД-troubleshooting.
|
||||
- `DEVELOPER-GUIDE.md` (332 строки) — локальный setup, тесты,
|
||||
паттерны (controller + entity + валидация + логирование), "НЕ
|
||||
делать" список.
|
||||
|
||||
### 2026-06-07 п.5 (k6 baseline)
|
||||
k6 v0.55.0 standalone в `~/bin/k6`. 3 скрипта в `tests/load/`:
|
||||
|
||||
- `signup-burst.js`: 50 RPM → p95 446ms, 0% errors. 100 RPM → 39% 429
|
||||
(IP-лимит работает, by design).
|
||||
- `retail-sales-parallel.js`: VU=1 — 17 sales/sec, p95 71ms, 0%
|
||||
failures. VU=5 — **53% failure** из-за race в `GenerateNumberAsync`
|
||||
(unique violation на `RetailSale.Number`). Это **реальная находка**,
|
||||
P0 для следующего спринта.
|
||||
- `sales-report-heavy.js`: на tenant'е с 1500 чеков, VU=1 — p95 54ms,
|
||||
VU=4 — p95 81ms, VU=5 — p95 114ms (один аномальный прогон показал
|
||||
3.8с — autovacuum suspect).
|
||||
|
||||
Все цифры в `docs/performance-baseline.md` с воспроизведением.
|
||||
|
||||
### 2026-06-07 п.6 (CI workflow)
|
||||
`.forgejo/workflows/stage-verify.yml` — `on: workflow_run` после
|
||||
`Docker API` и `Docker Web`, не запускается на failed parent (нет
|
||||
смысла верифировать незадеплоенное). Шаги: wait-for-ready (60с
|
||||
retry loop) → запустить `tests/stage-smoke.sh` → Telegram пинг.
|
||||
|
||||
`tests/stage-smoke.sh` — bash-скрипт без зависимостей кроме
|
||||
curl+jq+python3. 5 этапов: health, signup A, token A, multi-tenant
|
||||
isolation (A создаёт продукт, B получает 404 + список без продукта A),
|
||||
полный документ-цикл (supplier+supply.post → проверка stock=100 →
|
||||
sale.post → проверка stock=99). Локальный прогон против stage —
|
||||
**7 секунд**, всё зелёное.
|
||||
|
||||
### Итог
|
||||
|
||||
Все 6 пунктов ✓. Документация:
|
||||
- 4 новых файла в `docs/` (~1300 строк суммарно).
|
||||
- `docs/performance-baseline.md` — реальные цифры + 1 находка P0.
|
||||
|
||||
Тестирование:
|
||||
- 3 k6 скрипта в `tests/load/`.
|
||||
- `tests/stage-smoke.sh` — 7-секундный smoke против stage.
|
||||
|
||||
CI:
|
||||
- `.forgejo/workflows/stage-verify.yml` — auto-verify на каждый
|
||||
successful deploy.
|
||||
|
||||
Следующие шаги, требующие user'а (за пределами автономного режима):
|
||||
1. Реальный ОФД ApiKey (Webkassa предпочтительно) — Sprint 11-fiscal
|
||||
ждёт это для активации.
|
||||
2. Решение по прод-деплой (домен + cert + DNS).
|
||||
3. MoySklad webhook-токены для inline-импорта.
|
||||
4. Windows-машина (или CI runner) для POS WPF сборки.
|
||||
5. Казахский переводчик для UI (i18n уже подготовлен).
|
||||
6. Реальный SMTP-провайдер для платформы (Mailgun / Postmark / Yandex).
|
||||
|
||||
Plus P0-задача из baseline'а: исправить race в `GenerateNumberAsync`
|
||||
для `RetailSalesController` и аналогичных контроллеров — это уже
|
||||
автономно делается, но требует дизайн-решения (per-tenant sequence vs
|
||||
counter table vs retry-loop).
|
||||
50
tests/load/README.md
Normal file
50
tests/load/README.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# k6 нагрузочные тесты
|
||||
|
||||
Сценарии нагрузочного тестирования API food-market через
|
||||
[k6](https://k6.io/).
|
||||
|
||||
## Подготовка
|
||||
|
||||
```bash
|
||||
# k6 standalone (Linux)
|
||||
wget -O- https://github.com/grafana/k6/releases/download/v0.55.0/k6-v0.55.0-linux-amd64.tar.gz | tar xz
|
||||
mv k6-*-linux-amd64/k6 ~/bin/
|
||||
|
||||
# проверить
|
||||
k6 version
|
||||
```
|
||||
|
||||
## Сценарии
|
||||
|
||||
| Файл | Что меряет |
|
||||
|---|---|
|
||||
| `signup-burst.js` | 100 signup'ов за минуту — bootstrap новых tenant'ов под нагрузкой |
|
||||
| `retail-sales-parallel.js` | 1000 проведённых чеков параллельно за 5 минут (один tenant) |
|
||||
| `sales-report-heavy.js` | Чтение отчёта `/api/reports/sales` при 10 000 уже-существующих продажах |
|
||||
|
||||
## Запуск против stage
|
||||
|
||||
```bash
|
||||
BASE_URL=https://test.admin.food-market.kz \
|
||||
k6 run signup-burst.js
|
||||
|
||||
BASE_URL=https://test.admin.food-market.kz \
|
||||
k6 run retail-sales-parallel.js
|
||||
|
||||
BASE_URL=https://test.admin.food-market.kz \
|
||||
k6 run sales-report-heavy.js
|
||||
```
|
||||
|
||||
Результаты сводятся в `docs/performance-baseline.md`.
|
||||
|
||||
## Что k6 печатает
|
||||
|
||||
```
|
||||
checks_total....: 100.00% 1000/1000
|
||||
http_req_duration: p(50)=82ms p(95)=312ms p(99)=580ms
|
||||
http_req_failed.: 0.00% 0/1000
|
||||
iteration_duration: avg=512ms
|
||||
```
|
||||
|
||||
Это «человеческие» метрики. Тонкое — через `--out json=summary.json`
|
||||
или `--out csv=...` (k6 не имеет своего хранилища, только stdout/file).
|
||||
173
tests/load/retail-sales-parallel.js
Normal file
173
tests/load/retail-sales-parallel.js
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
// retail-sales-parallel.js — нагрузка на проведение чеков.
|
||||
//
|
||||
// Сценарий: один tenant создан вне теста (или одним setup), все VU
|
||||
// проводят чеки параллельно. Цель — увидеть p95/p99 операции
|
||||
// «создать draft → /post → дождаться NoContent» под нагрузкой.
|
||||
//
|
||||
// По умолчанию: 1000 итераций за 5 минут (~3.3 RPS). Подкручивается
|
||||
// через DURATION_S и TARGET_ITERS env'ами.
|
||||
//
|
||||
// ВАЖНО:
|
||||
// - Перед запуском нужен tenant с продуктом и приёмкой. setup()
|
||||
// делает всё сам: signup → seed product → post supply на 10 000 шт.
|
||||
// Один setup на запуск — потом VU работают параллельно с тем же
|
||||
// токеном. Если итерация не находит остатка (например, тестовый
|
||||
// tenant закончился) — фейлим check.
|
||||
// - Запуск против stage заметно дольше из-за сетевого RTT (~30-100мс
|
||||
// на запрос). Локально на dev-vm обычно в 3-5 раз быстрее.
|
||||
|
||||
import http from 'k6/http';
|
||||
import { check } from 'k6';
|
||||
import { Trend, Rate, Counter } from 'k6/metrics';
|
||||
|
||||
const BASE_URL = __ENV.BASE_URL || 'http://localhost:5081';
|
||||
const DURATION_S = Number(__ENV.DURATION_S || 300); // 5 минут
|
||||
const TARGET_ITERS = Number(__ENV.TARGET_ITERS || 1000);
|
||||
|
||||
const draftTrend = new Trend('sale_draft_ms', true);
|
||||
const postTrend = new Trend('sale_post_ms', true);
|
||||
const totalTrend = new Trend('sale_total_ms', true);
|
||||
const post4xx = new Rate('post_4xx');
|
||||
const completed = new Counter('sales_completed');
|
||||
|
||||
const VUS = Number(__ENV.VUS || 5);
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
retail_sales: {
|
||||
executor: 'shared-iterations',
|
||||
iterations: TARGET_ITERS,
|
||||
vus: VUS, // дефолт 5 параллельных кассиров
|
||||
maxDuration: `${DURATION_S}s`,
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
// p95 «draft + post» на stage обычно 200-700ms.
|
||||
sale_total_ms: ['p(95)<3000', 'p(99)<6000'],
|
||||
// ⚠️ При VU > 5 на одном tenant'е будут конфликты на уникальный
|
||||
// индекс RetailSale.Number (генератор номера расы) — это
|
||||
// отдельная задача для оптимизации (см. performance-baseline.md).
|
||||
post_4xx: ['rate<0.10'],
|
||||
},
|
||||
};
|
||||
|
||||
// ── setup: один раз для всех VU ────────────────────────────────────────
|
||||
|
||||
export function setup() {
|
||||
const slug = `load-sales-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const email = `${slug}@example.kz`;
|
||||
const password = 'Passw0rd!';
|
||||
|
||||
// 1. Signup
|
||||
let r = http.post(`${BASE_URL}/api/auth/signup`, JSON.stringify({
|
||||
email, password, organizationName: `LoadOrg-${slug}`,
|
||||
phone: '+77001234567', plan: null,
|
||||
}), { headers: { 'Content-Type': 'application/json' } });
|
||||
if (r.status >= 300) throw new Error(`signup failed: ${r.status} ${r.body}`);
|
||||
|
||||
// 2. Token
|
||||
r = http.post(`${BASE_URL}/connect/token`,
|
||||
`grant_type=password&username=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}&client_id=food-market-web&scope=openid profile email roles api offline_access`,
|
||||
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
|
||||
if (r.status !== 200) throw new Error(`token failed: ${r.status} ${r.body}`);
|
||||
const token = r.json('access_token');
|
||||
const auth = { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` } };
|
||||
|
||||
// 3. Достаём refs (unit/group/store/currency/priceType)
|
||||
const unitsRes = http.get(`${BASE_URL}/api/catalog/units-of-measure?pageSize=200`, auth);
|
||||
const unit = unitsRes.json('items').find(u => u.code === '796');
|
||||
const groupsRes = http.get(`${BASE_URL}/api/catalog/product-groups`, auth);
|
||||
const group = groupsRes.json('items')[0];
|
||||
const ptsRes = http.get(`${BASE_URL}/api/catalog/price-types`, auth);
|
||||
const pt = ptsRes.json('items').find(p => p.isRetail);
|
||||
const cursRes = http.get(`${BASE_URL}/api/catalog/currencies`, auth);
|
||||
const cur = cursRes.json('items').find(c => c.code === 'KZT');
|
||||
const storesRes = http.get(`${BASE_URL}/api/catalog/stores`, auth);
|
||||
const store = storesRes.json('items').find(s => s.isMain);
|
||||
const rpsRes = http.get(`${BASE_URL}/api/catalog/retail-points`, auth);
|
||||
const retailPoint = rpsRes.json('items')[0];
|
||||
|
||||
// 4. Создаём продукт
|
||||
const prodRes = http.post(`${BASE_URL}/api/catalog/products`, JSON.stringify({
|
||||
name: 'Load test product',
|
||||
article: `LT-${slug}`,
|
||||
unitOfMeasureId: unit.id,
|
||||
vat: 12, vatEnabled: true,
|
||||
productGroupId: group.id,
|
||||
packaging: 1,
|
||||
prices: [{ priceTypeId: pt.id, amount: 100, currencyId: cur.id }],
|
||||
barcodes: [{ code: `9990000${Math.floor(Math.random()*1e6).toString().padStart(7,'0')}`, type: 1, isPrimary: true }],
|
||||
}), auth);
|
||||
if (prodRes.status >= 300) throw new Error(`product failed: ${prodRes.status} ${prodRes.body}`);
|
||||
const productId = prodRes.json('id');
|
||||
|
||||
// 5. Поставщик + приёмка на 10 000 шт.
|
||||
const supRes = http.post(`${BASE_URL}/api/catalog/counterparties`,
|
||||
JSON.stringify({ name: 'LoadSupplier', type: 2 }), auth);
|
||||
const supplierId = supRes.json('id');
|
||||
|
||||
const supplyRes = http.post(`${BASE_URL}/api/purchases/supplies`, JSON.stringify({
|
||||
date: new Date().toISOString(),
|
||||
supplierId, storeId: store.id, currencyId: cur.id,
|
||||
lines: [{ productId, quantity: 10000, unitPrice: 50 }],
|
||||
}), auth);
|
||||
const supplyId = supplyRes.json('id');
|
||||
const postSupplyRes = http.post(`${BASE_URL}/api/purchases/supplies/${supplyId}/post`, null, auth);
|
||||
if (postSupplyRes.status >= 300) throw new Error(`supply post failed: ${postSupplyRes.status}`);
|
||||
|
||||
return {
|
||||
token, productId,
|
||||
storeId: store.id,
|
||||
retailPointId: retailPoint.id,
|
||||
currencyId: cur.id,
|
||||
};
|
||||
}
|
||||
|
||||
// ── default fn: каждая итерация = один проведённый чек ─────────────────
|
||||
|
||||
export default function (ctx) {
|
||||
const auth = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${ctx.token}`,
|
||||
},
|
||||
};
|
||||
|
||||
const t0 = Date.now();
|
||||
|
||||
// 1. Создать draft (POST /api/sales/retail)
|
||||
const draftPayload = JSON.stringify({
|
||||
date: new Date().toISOString(),
|
||||
storeId: ctx.storeId,
|
||||
retailPointId: ctx.retailPointId,
|
||||
customerId: null,
|
||||
currencyId: ctx.currencyId,
|
||||
payment: 0, isReturn: false,
|
||||
lines: [{
|
||||
productId: ctx.productId,
|
||||
quantity: 1, unitPrice: 100, discount: 0, vatPercent: 12,
|
||||
}],
|
||||
subtotal: 100, discountTotal: 0, total: 100,
|
||||
paidCash: 100, paidCard: 0,
|
||||
notes: 'k6-load',
|
||||
});
|
||||
const tDraft0 = Date.now();
|
||||
const draftRes = http.post(`${BASE_URL}/api/sales/retail`, draftPayload, auth);
|
||||
draftTrend.add(Date.now() - tDraft0);
|
||||
const draftOk = check(draftRes, { 'draft 2xx': (r) => r.status >= 200 && r.status < 300 });
|
||||
if (!draftOk) {
|
||||
post4xx.add(1);
|
||||
return;
|
||||
}
|
||||
const saleId = draftRes.json('id');
|
||||
|
||||
// 2. Провести (POST /api/sales/retail/{id}/post)
|
||||
const tPost0 = Date.now();
|
||||
const postRes = http.post(`${BASE_URL}/api/sales/retail/${saleId}/post`, null, auth);
|
||||
postTrend.add(Date.now() - tPost0);
|
||||
post4xx.add(postRes.status >= 400);
|
||||
check(postRes, { 'post 204': (r) => r.status === 204 });
|
||||
|
||||
totalTrend.add(Date.now() - t0);
|
||||
if (postRes.status === 204) completed.add(1);
|
||||
}
|
||||
78
tests/load/sales-report-heavy.js
Normal file
78
tests/load/sales-report-heavy.js
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
// sales-report-heavy.js — нагрузка чтения отчётов.
|
||||
//
|
||||
// Сценарий: один tenant с уже-нагенерированными чеками (1500 через
|
||||
// YearDemoSeeder или 10000+ через несколько подряд запусков
|
||||
// retail-sales-parallel.js). 30 секунд VU дёргают
|
||||
// GET /api/reports/sales и GET /api/reports/abc, мерим p95/p99.
|
||||
//
|
||||
// Это «чтение тяжёлого агрегата», самый показательный bench для
|
||||
// PG-индексов и плана запроса. Если p95 > 2с при 1500 чеках —
|
||||
// нужен EXPLAIN.
|
||||
|
||||
import http from 'k6/http';
|
||||
import { check } from 'k6';
|
||||
import { Trend, Rate } from 'k6/metrics';
|
||||
|
||||
const BASE_URL = __ENV.BASE_URL || 'http://localhost:5081';
|
||||
const DURATION_S = Number(__ENV.DURATION_S || 30);
|
||||
const VUS = Number(__ENV.VUS || 10);
|
||||
|
||||
// Email/пароль уже-существующего tenant'а — нужно подсунуть. По дефолту
|
||||
// — admin@food-market.local (SuperAdmin без своей орги, отчёты будут
|
||||
// пустыми, но мы мерим только время ответа эндпоинтов).
|
||||
const EMAIL = __ENV.EMAIL || 'admin@food-market.local';
|
||||
const PASSWORD = __ENV.PASSWORD || 'Admin12345!';
|
||||
|
||||
const salesTrend = new Trend('reports_sales_ms', true);
|
||||
const abcTrend = new Trend('reports_abc_ms', true);
|
||||
const dashboardTrend = new Trend('dashboard_top_ms', true);
|
||||
const non2xx = new Rate('reports_non_2xx');
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
reports: {
|
||||
executor: 'constant-vus',
|
||||
vus: VUS,
|
||||
duration: `${DURATION_S}s`,
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
reports_sales_ms: ['p(95)<2500', 'p(99)<5000'],
|
||||
reports_abc_ms: ['p(95)<3000', 'p(99)<6000'],
|
||||
reports_non_2xx: ['rate<0.01'],
|
||||
},
|
||||
};
|
||||
|
||||
export function setup() {
|
||||
const r = http.post(`${BASE_URL}/connect/token`,
|
||||
`grant_type=password&username=${encodeURIComponent(EMAIL)}&password=${encodeURIComponent(PASSWORD)}&client_id=food-market-web&scope=openid profile email roles api offline_access`,
|
||||
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
|
||||
if (r.status !== 200) throw new Error(`token failed for ${EMAIL}: ${r.status} ${r.body}`);
|
||||
return { token: r.json('access_token') };
|
||||
}
|
||||
|
||||
export default function (ctx) {
|
||||
const auth = { headers: { Authorization: `Bearer ${ctx.token}` }, tags: {} };
|
||||
|
||||
// Период «весь год» — стандартный запрос дашборда «за год».
|
||||
const dateFrom = '2026-01-01';
|
||||
const dateTo = '2026-12-31';
|
||||
|
||||
let t0 = Date.now();
|
||||
let r = http.get(`${BASE_URL}/api/reports/sales?dateFrom=${dateFrom}&dateTo=${dateTo}&groupBy=day`, auth);
|
||||
salesTrend.add(Date.now() - t0);
|
||||
non2xx.add(r.status >= 400);
|
||||
check(r, { 'sales 2xx': (x) => x.status >= 200 && x.status < 300 });
|
||||
|
||||
t0 = Date.now();
|
||||
r = http.get(`${BASE_URL}/api/reports/abc?dateFrom=${dateFrom}&dateTo=${dateTo}`, auth);
|
||||
abcTrend.add(Date.now() - t0);
|
||||
non2xx.add(r.status >= 400);
|
||||
check(r, { 'abc 2xx': (x) => x.status >= 200 && x.status < 300 });
|
||||
|
||||
t0 = Date.now();
|
||||
r = http.get(`${BASE_URL}/api/dashboard/top-products?limit=5`, auth);
|
||||
dashboardTrend.add(Date.now() - t0);
|
||||
non2xx.add(r.status >= 400);
|
||||
check(r, { 'dashboard 2xx': (x) => x.status >= 200 && x.status < 300 });
|
||||
}
|
||||
69
tests/load/signup-burst.js
Normal file
69
tests/load/signup-burst.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// signup-burst.js — нагрузочный тест регистрации новых tenant'ов.
|
||||
//
|
||||
// Сценарий: 100 signup'ов в минуту, ramping VU от 1 до 20 и обратно.
|
||||
//
|
||||
// ВАЖНО: на stage IP-лимит rate-limiter'а — 60/мин на /api/auth/signup
|
||||
// и /connect/token (DefaultPerIpPerMinute=60, см.
|
||||
// AuthRateLimiterExtensions). Запуская 100/мин с одного IP, мы УПРЁМСЯ
|
||||
// в 429 — это нормальный исход теста. Метрика «процент 429» показывает,
|
||||
// насколько IP-лимит держит вход; метрика «латенция при <60 rps» —
|
||||
// чистая производительность стэка.
|
||||
//
|
||||
// Чтобы по-честному померить bootstrap БЕЗ упирания в лимит — уменьшить
|
||||
// до 50 RPS (через TARGET_RPM=50).
|
||||
|
||||
import http from 'k6/http';
|
||||
import { check } from 'k6';
|
||||
import { Trend, Rate } from 'k6/metrics';
|
||||
|
||||
const BASE_URL = __ENV.BASE_URL || 'http://localhost:5081';
|
||||
const TARGET_RPM = Number(__ENV.TARGET_RPM || 100);
|
||||
const DURATION_S = 60;
|
||||
const PER_SECOND = TARGET_RPM / 60;
|
||||
|
||||
const signupTrend = new Trend('signup_duration_ms', true);
|
||||
const signup429 = new Rate('signup_rate_limited');
|
||||
|
||||
export const options = {
|
||||
scenarios: {
|
||||
signup_burst: {
|
||||
executor: 'constant-arrival-rate',
|
||||
rate: TARGET_RPM,
|
||||
timeUnit: '1m',
|
||||
duration: `${DURATION_S}s`,
|
||||
preAllocatedVUs: 20,
|
||||
maxVUs: 40,
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
// Прагматичные пороги. p95 на dev-stack обычно < 1.5с для signup
|
||||
// (создание Organization + User + Employee + Store + RetailPoint).
|
||||
http_req_duration: ['p(95)<3000', 'p(99)<6000'],
|
||||
// 429 — допустимо, но не должно быть >50% (тогда тест не информативен).
|
||||
signup_rate_limited: ['rate<0.7'],
|
||||
},
|
||||
};
|
||||
|
||||
export default function () {
|
||||
const id = `${__VU}-${__ITER}-${Date.now()}`;
|
||||
const email = `load-signup-${id}@example.kz`;
|
||||
const payload = JSON.stringify({
|
||||
email,
|
||||
password: 'Passw0rd!',
|
||||
organizationName: `LoadOrg-${id}`,
|
||||
phone: '+77001234567',
|
||||
plan: null,
|
||||
});
|
||||
|
||||
const t0 = Date.now();
|
||||
const res = http.post(`${BASE_URL}/api/auth/signup`, payload, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
tags: { name: 'signup' },
|
||||
});
|
||||
signupTrend.add(Date.now() - t0);
|
||||
signup429.add(res.status === 429);
|
||||
|
||||
check(res, {
|
||||
'status is 2xx or 429': (r) => r.status >= 200 && r.status < 300 || r.status === 429,
|
||||
});
|
||||
}
|
||||
158
tests/stage-smoke.sh
Executable file
158
tests/stage-smoke.sh
Executable file
|
|
@ -0,0 +1,158 @@
|
|||
#!/usr/bin/env bash
|
||||
# stage-smoke.sh — быстрый smoke-suite для stage-verify CI.
|
||||
#
|
||||
# Что проверяется:
|
||||
# 1. /health/ready → 200, status=Healthy.
|
||||
# 2. Signup tenant A → 2xx.
|
||||
# 3. Token tenant A → access_token.
|
||||
# 4. Multi-tenant: signup tenant B, проверка что products tenant'а A
|
||||
# НЕ видны под токеном tenant'а B (изоляция).
|
||||
# 5. Один полный документ-цикл tenant'а A:
|
||||
# create product → create supplier → create supply → post supply
|
||||
# → проверка остатка > 0 → create retail sale → post retail sale
|
||||
# → 204.
|
||||
#
|
||||
# Зависит от: curl, jq, python3 (для генерации UUID barcode).
|
||||
# Запуск: BASE_URL=https://test.admin.food-market.kz bash tests/stage-smoke.sh
|
||||
# Без BASE_URL — http://localhost:5081.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://localhost:5081}"
|
||||
SLUG="smoke-$(date +%s)-$RANDOM"
|
||||
|
||||
log() { echo "[$(date -Is)] $*"; }
|
||||
fail() { echo "[FAIL] $*" >&2; exit 1; }
|
||||
|
||||
curl_json() {
|
||||
# curl_json METHOD URL [BODY] [TOKEN]
|
||||
local method="$1" url="$2" body="${3:-}" token="${4:-}"
|
||||
local args=(-sS -X "$method" -w '\n%{http_code}' -H 'Content-Type: application/json')
|
||||
[ -n "$token" ] && args+=(-H "Authorization: Bearer $token")
|
||||
[ -n "$body" ] && args+=(-d "$body")
|
||||
curl "${args[@]}" "$url"
|
||||
}
|
||||
|
||||
# Возвращает HTTP-код из последней строки curl_json
|
||||
last_status() { echo "$1" | tail -n1; }
|
||||
body_only() { echo "$1" | sed '$d'; }
|
||||
|
||||
# ── 1. /health/ready ──────────────────────────────────────────────────
|
||||
log "[1/5] /health/ready"
|
||||
if ! curl -fsS "$BASE_URL/health/ready" | jq -e '.status == "Healthy"' > /dev/null; then
|
||||
fail "/health/ready вернул не Healthy"
|
||||
fi
|
||||
|
||||
# ── 2. Signup tenant A ────────────────────────────────────────────────
|
||||
log "[2/5] signup tenant A"
|
||||
EMAIL_A="$SLUG-a@example.kz"
|
||||
PASSWORD="Passw0rd!"
|
||||
RES=$(curl_json POST "$BASE_URL/api/auth/signup" \
|
||||
"{\"email\":\"$EMAIL_A\",\"password\":\"$PASSWORD\",\"organizationName\":\"SmokeOrgA-$SLUG\",\"phone\":\"+77001234567\"}")
|
||||
[ "$(last_status "$RES")" = "200" ] || fail "signup A: $(last_status "$RES") body=$(body_only "$RES")"
|
||||
|
||||
# ── 3. Token tenant A ─────────────────────────────────────────────────
|
||||
log "[3/5] token tenant A"
|
||||
TOKEN_A=$(curl -sSf -X POST "$BASE_URL/connect/token" \
|
||||
-d "grant_type=password&username=$EMAIL_A&password=$PASSWORD&client_id=food-market-web&scope=openid profile email roles api offline_access" \
|
||||
| jq -r .access_token)
|
||||
[ -n "$TOKEN_A" ] && [ "$TOKEN_A" != "null" ] || fail "token A пустой"
|
||||
|
||||
# ── 4. Multi-tenant: tenant B не видит product tenant'а A ────────────
|
||||
log "[4/5] multi-tenant isolation"
|
||||
# Tenant A создаёт продукт
|
||||
UNIT_ID=$(curl -sSf -H "Authorization: Bearer $TOKEN_A" "$BASE_URL/api/catalog/units-of-measure?pageSize=200" \
|
||||
| jq -r '.items[] | select(.code=="796") | .id')
|
||||
GROUP_ID=$(curl -sSf -H "Authorization: Bearer $TOKEN_A" "$BASE_URL/api/catalog/product-groups" \
|
||||
| jq -r '.items[0].id')
|
||||
PT_ID=$(curl -sSf -H "Authorization: Bearer $TOKEN_A" "$BASE_URL/api/catalog/price-types" \
|
||||
| jq -r '.items[] | select(.isRetail) | .id')
|
||||
CUR_ID=$(curl -sSf -H "Authorization: Bearer $TOKEN_A" "$BASE_URL/api/catalog/currencies" \
|
||||
| jq -r '.items[] | select(.code=="KZT") | .id')
|
||||
STORE_ID=$(curl -sSf -H "Authorization: Bearer $TOKEN_A" "$BASE_URL/api/catalog/stores" \
|
||||
| jq -r '.items[] | select(.isMain) | .id')
|
||||
|
||||
PRODUCT_NAME="SmokeProd-$SLUG"
|
||||
BARCODE=$(python3 -c "import random; print(''.join([str(random.randint(0,9)) for _ in range(13)]))")
|
||||
PRODUCT_BODY=$(jq -nc \
|
||||
--arg name "$PRODUCT_NAME" --arg art "ART-$SLUG" --arg unit "$UNIT_ID" --arg group "$GROUP_ID" \
|
||||
--arg pt "$PT_ID" --arg cur "$CUR_ID" --arg bc "$BARCODE" \
|
||||
'{name:$name, article:$art, unitOfMeasureId:$unit, vat:12, vatEnabled:true,
|
||||
productGroupId:$group, packaging:1,
|
||||
prices:[{priceTypeId:$pt, amount:100, currencyId:$cur}],
|
||||
barcodes:[{code:$bc, type:1, isPrimary:true}]}')
|
||||
PROD_RES=$(curl -sSf -X POST -H "Authorization: Bearer $TOKEN_A" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$PRODUCT_BODY" "$BASE_URL/api/catalog/products")
|
||||
PRODUCT_ID=$(echo "$PROD_RES" | jq -r .id)
|
||||
[ -n "$PRODUCT_ID" ] && [ "$PRODUCT_ID" != "null" ] || fail "product create A failed: $PROD_RES"
|
||||
|
||||
# Tenant B регистрируется и забирает токен
|
||||
EMAIL_B="$SLUG-b@example.kz"
|
||||
curl -sSf -X POST -H 'Content-Type: application/json' \
|
||||
-d "{\"email\":\"$EMAIL_B\",\"password\":\"$PASSWORD\",\"organizationName\":\"SmokeOrgB-$SLUG\",\"phone\":\"+77001234568\"}" \
|
||||
"$BASE_URL/api/auth/signup" > /dev/null
|
||||
TOKEN_B=$(curl -sSf -X POST "$BASE_URL/connect/token" \
|
||||
-d "grant_type=password&username=$EMAIL_B&password=$PASSWORD&client_id=food-market-web&scope=openid profile email roles api offline_access" \
|
||||
| jq -r .access_token)
|
||||
|
||||
# Tenant B пытается прочитать конкретный продукт A — должен получить 404
|
||||
B_GET_STATUS=$(curl -sS -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: Bearer $TOKEN_B" "$BASE_URL/api/catalog/products/$PRODUCT_ID")
|
||||
[ "$B_GET_STATUS" = "404" ] || fail "🔴 multi-tenant LEAK: B видит product A ($B_GET_STATUS)"
|
||||
# Tenant B запрашивает список — продукт A не должен быть в списке
|
||||
B_LIST=$(curl -sSf -H "Authorization: Bearer $TOKEN_B" "$BASE_URL/api/catalog/products?pageSize=500")
|
||||
B_HAS_A=$(echo "$B_LIST" | jq --arg name "$PRODUCT_NAME" 'any(.items[]; .name == $name)')
|
||||
[ "$B_HAS_A" = "false" ] || fail "🔴 multi-tenant LEAK: B видит product A в списке"
|
||||
log " isolation OK"
|
||||
|
||||
# ── 5. Полный документ-цикл tenant'а A ────────────────────────────────
|
||||
log "[5/5] supply + retail sale цикл"
|
||||
|
||||
# Supplier
|
||||
SUP_ID=$(curl -sSf -X POST -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
|
||||
-d '{"name":"SmokeSupplier","type":2}' \
|
||||
"$BASE_URL/api/catalog/counterparties" | jq -r .id)
|
||||
|
||||
# Supply
|
||||
SUPPLY_BODY=$(jq -nc \
|
||||
--arg sup "$SUP_ID" --arg store "$STORE_ID" --arg cur "$CUR_ID" --arg prod "$PRODUCT_ID" \
|
||||
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
'{date:$date, supplierId:$sup, storeId:$store, currencyId:$cur,
|
||||
lines:[{productId:$prod, quantity:100, unitPrice:50}]}')
|
||||
SUPPLY_ID=$(curl -sSf -X POST -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
|
||||
-d "$SUPPLY_BODY" "$BASE_URL/api/purchases/supplies" | jq -r .id)
|
||||
SUPPLY_POST=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H "Authorization: Bearer $TOKEN_A" "$BASE_URL/api/purchases/supplies/$SUPPLY_ID/post")
|
||||
[ "$SUPPLY_POST" = "204" ] || fail "supply post: $SUPPLY_POST"
|
||||
|
||||
# Stock check (должен стать 100)
|
||||
STOCK=$(curl -sSf -H "Authorization: Bearer $TOKEN_A" \
|
||||
"$BASE_URL/api/inventory/stock?productId=$PRODUCT_ID&pageSize=10" \
|
||||
| jq -r --arg p "$PRODUCT_ID" '.items[] | select(.productId==$p) | .quantity')
|
||||
# .quantity приходит как "100.0000" (precision 18,4) — сравниваем численно
|
||||
awk -v s="$STOCK" 'BEGIN{exit !(s+0 == 100)}' || fail "stock после supply: ожидали 100, получили '$STOCK'"
|
||||
|
||||
# Retail sale
|
||||
RP_ID=$(curl -sSf -H "Authorization: Bearer $TOKEN_A" "$BASE_URL/api/catalog/retail-points" \
|
||||
| jq -r '.items[0].id')
|
||||
SALE_BODY=$(jq -nc \
|
||||
--arg store "$STORE_ID" --arg rp "$RP_ID" --arg cur "$CUR_ID" --arg prod "$PRODUCT_ID" \
|
||||
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
'{date:$date, storeId:$store, retailPointId:$rp, customerId:null, currencyId:$cur,
|
||||
payment:0, isReturn:false,
|
||||
lines:[{productId:$prod, quantity:1, unitPrice:100, discount:0, vatPercent:12}],
|
||||
subtotal:100, discountTotal:0, total:100, paidCash:100, paidCard:0, notes:"smoke"}')
|
||||
SALE_ID=$(curl -sSf -X POST -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \
|
||||
-d "$SALE_BODY" "$BASE_URL/api/sales/retail" | jq -r .id)
|
||||
SALE_POST=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
|
||||
-H "Authorization: Bearer $TOKEN_A" "$BASE_URL/api/sales/retail/$SALE_ID/post")
|
||||
[ "$SALE_POST" = "204" ] || fail "retail-sale post: $SALE_POST"
|
||||
|
||||
# Stock после продажи должен стать 99
|
||||
STOCK_AFTER=$(curl -sSf -H "Authorization: Bearer $TOKEN_A" \
|
||||
"$BASE_URL/api/inventory/stock?productId=$PRODUCT_ID&pageSize=10" \
|
||||
| jq -r --arg p "$PRODUCT_ID" '.items[] | select(.productId==$p) | .quantity')
|
||||
awk -v s="$STOCK_AFTER" 'BEGIN{exit !(s+0 == 99)}' || fail "stock после sale: ожидали 99, получили '$STOCK_AFTER'"
|
||||
|
||||
log "✅ smoke OK ($SLUG)"
|
||||
Loading…
Reference in a new issue