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