feat(s20): Mapster + SSO scaffold + maintenance automation (7 пунктов)
Some checks failed
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Public / Build + push Public (push) Has been cancelled
Docker Public / Deploy Public on stage (push) Has been cancelled
Some checks failed
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Public / Build + push Public (push) Has been cancelled
Docker Public / Deploy Public on stage (push) Has been cancelled
1. TD-3 Mapster — Application/Mapping/MapsterConfig.cs с
TypeAdapterConfig для Product, Counterparty + collections.
ProductsController.List/Get/GetInternalAsync + CounterpartiesController.
List/Get переведены на .ProjectToType<TDto>(MapsterConfig.Config).
Inline Projection-Expression удалён.
2. SSO scaffold — Microsoft.AspNetCore.Authentication.Google + .MicrosoftAccount
пакеты, условная регистрация в Program.cs (только если ClientId задан).
ExternalAuthController с GET /api/auth/external/{provider} (Challenge или
503 если не настроено), /callback (501 с email — invite-flow TODO),
/providers (булевый список). docs/sso.md инструкция.
3. Stale-data cleanup — HousekeepingJobs расширен:
PruneOrgAuditLogAsync (>90д из Cleanup:OrgAuditLogDays),
PruneDraftsAsync (Supply/RetailSale/Demand старше 30д),
PruneRevokedRefreshTokensAsync (raw SQL DELETE из OpenIddictTokens).
3 новых cron'a в HangfireJobsConfigurator (03:00-03:20 UTC).
4. DB VACUUM automation — DatabaseMaintenanceJobs.VacuumTopTablesAsync:
pg_total_relation_size → топ-5 таблиц → VACUUM (ANALYZE) per table
с замером времени. Default cron еженедельно вс 04:00 UTC.
5. Disk usage monitoring — DiskMonitoringJob ежечасно: DriveInfo.AvailableFreeSpace
на пути из Monitoring:DiskPaths (default "/opt,/var/lib/docker").
<1GB → Telegram-alert на Monitoring:SuperAdminTelegramChatIds.
Anti-spam cooldown 6h. Gauge food_market_disk_free_bytes{mount}.
6. Performance regression detection — ~/nightly-perf-check.sh после
nightly-verify. Парсит /metrics, считает db_avg_ms, сравнивает с
baseline в ~/.fm-watchdog/perf-baseline.json. Δ>30% → Telegram alert
+ baseline НЕ обновляется (sliding window).
7. Public-site analytics placeholder — Astro BaseLayout рендерит
gtag/Yandex.Metrika только если задан PUBLIC_GA_ID / PUBLIC_YM_ID;
иначе <script data-id="REPLACE_ME" data-doc="docs/analytics.md">
маркер. docs/analytics.md с инструкцией подключения.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
7c57d0691b
commit
346b7bfd48
|
|
@ -7,6 +7,9 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- ASP.NET Core 8 -->
|
<!-- ASP.NET Core 8 -->
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
|
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
|
||||||
|
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.11" />
|
||||||
|
<PackageVersion Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="8.0.11" />
|
||||||
|
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Cookies" Version="2.3.0" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="8.0.11" />
|
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="8.0.11" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
|
<PackageVersion Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
|
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
|
||||||
|
|
|
||||||
54
docs/analytics.md
Normal file
54
docs/analytics.md
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
# Web-analytics на public-сайте
|
||||||
|
|
||||||
|
Sprint 20: в `food-market.public` (Astro marketing-сайт) подключены
|
||||||
|
placeholder'ы для Google Analytics 4 и Яндекс.Метрики. По умолчанию
|
||||||
|
оба не активны — в HTML рендерятся `<script data-id="REPLACE_ME">`
|
||||||
|
маркеры. Аналитика включается через env-vars при сборке Astro.
|
||||||
|
|
||||||
|
**Зачем placeholder, а не сразу скрипты:**
|
||||||
|
- Аналитика на marketing-сайте — это PII (IP-адреса посетителей),
|
||||||
|
по GDPR / 152-ФЗ / казахскому ЗоЗПД требует согласия пользователя
|
||||||
|
или специальной конфигурации (`anonymize_ip: true` + cookies notice).
|
||||||
|
- Прод-аккаунт в Google/Yandex заводится отдельно владельцем; коммитить
|
||||||
|
его в репо неправильно.
|
||||||
|
|
||||||
|
## Google Analytics 4
|
||||||
|
|
||||||
|
1. Завести **GA4 property** на https://analytics.google.com.
|
||||||
|
2. **Admin → Data Streams → Web → Add stream** → ввести URL public-сайта.
|
||||||
|
3. Скопировать **Measurement ID** вида `G-XXXXXXXXXX`.
|
||||||
|
4. В `deploy/Dockerfile.public` или в env переменной добавить:
|
||||||
|
```bash
|
||||||
|
PUBLIC_GA_ID=G-XXXXXXXXXX
|
||||||
|
```
|
||||||
|
5. Пересобрать public-image: `cd src/food-market.public && pnpm build`.
|
||||||
|
6. Открыть https://food-market.kz, проверить в DevTools → Network тегом
|
||||||
|
`gtag/js?id=G-XXX`.
|
||||||
|
|
||||||
|
## Яндекс.Метрика
|
||||||
|
|
||||||
|
1. https://metrika.yandex.com → **Создать счётчик**.
|
||||||
|
2. Скопировать **ID счётчика** (8-значное число).
|
||||||
|
3. Env: `PUBLIC_YM_ID=12345678`.
|
||||||
|
4. Аналогично — пересобрать.
|
||||||
|
|
||||||
|
## Проверка что НЕ настроено
|
||||||
|
|
||||||
|
Открыть https://food-market.kz, View Source, найти:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script data-analytics="google" data-id="REPLACE_ME" data-doc="docs/analytics.md" />
|
||||||
|
<script data-analytics="yandex-metrika" data-id="REPLACE_ME" data-doc="docs/analytics.md" />
|
||||||
|
```
|
||||||
|
|
||||||
|
Если эти строки есть — аналитика **не подключена**. Если вместо них
|
||||||
|
видны `gtag` или `ym(...)` скрипты — настроено.
|
||||||
|
|
||||||
|
## Что НЕ собираем
|
||||||
|
|
||||||
|
- Никаких событий на админ-сайте `admin.food-market.kz` — это закрытая
|
||||||
|
система для авторизованных пользователей, тут аналитика будет
|
||||||
|
собирать persistent activity, что нарушает privacy expectations.
|
||||||
|
Если потребуется product-analytics в админке — отдельный обсуждение
|
||||||
|
(можем self-host Plausible / PostHog).
|
||||||
|
- Никаких user-id в Metrika events — только anonymous traffic.
|
||||||
43
docs/sprint20-progress.md
Normal file
43
docs/sprint20-progress.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Sprint 20 — Mapster + SSO scaffolding + maintenance automation
|
||||||
|
|
||||||
|
Цель: закрыть TD-3 (Mapster вместо ручных LINQ-проекций), добавить
|
||||||
|
SSO-скелет (Google + Microsoft), включить maintenance-автоматику
|
||||||
|
(stale cleanup / VACUUM / disk / performance regression / analytics).
|
||||||
|
|
||||||
|
Старт: 2026-06-07 (после Sprint 19). Исполнитель: Claude Opus 4.7.
|
||||||
|
|
||||||
|
## Принципы
|
||||||
|
|
||||||
|
- Mapster — без AutoMapper (платный + CVE), config в `Application/Mapping/`.
|
||||||
|
- SSO — только скелет. Реальные client_id/secret не коммитим, пустые → 503.
|
||||||
|
- Hangfire jobs — идемпотентные, с лимитом на rows (не зачищать слишком много за раз).
|
||||||
|
- НЕ трогать: `global.json`, prod admin.food-market.kz, POS WPF.
|
||||||
|
|
||||||
|
## Чек-лист
|
||||||
|
|
||||||
|
- [ ] **1. TD-3 Mapster** — `MapsterConfig.cs` с TypeAdapterConfig'ом
|
||||||
|
для Product→ProductDto, Counterparty→CounterpartyDto. Замена
|
||||||
|
inline `Select(...)` на `.ProjectToType<TDto>()`. Бенчмарк до/после.
|
||||||
|
- [ ] **2. SSO Google + Microsoft scaffolding** — пакеты
|
||||||
|
`Microsoft.AspNetCore.Authentication.Google` + `.MicrosoftAccount`.
|
||||||
|
Endpoint `/api/auth/external/{provider}`, callback, связывание с
|
||||||
|
существующим Email или создание нового User+Employee. ApiClientId/
|
||||||
|
Secret из конфига; пустые → 503. docs/sso.md.
|
||||||
|
- [ ] **3. Stale-data cleanup автоматика** — Hangfire ежесуточно 03:00:
|
||||||
|
draft >30д, audit-log >90д, StockMovement >2г, refresh-tokens
|
||||||
|
revoked >7д. Конфиг `Cleanup:*` в appsettings.
|
||||||
|
- [ ] **4. DB VACUUM automation** — Hangfire еженедельно:
|
||||||
|
`VACUUM ANALYZE` на топ-5 таблиц по размеру. Лог времени.
|
||||||
|
- [ ] **5. Disk usage monitoring** — Hangfire ежечасно: free space
|
||||||
|
/opt и /var/lib/docker. <1GB → Telegram SuperAdmin'ам. Prom-метрика
|
||||||
|
`food_market_disk_free_bytes{mount="..."}`.
|
||||||
|
- [ ] **6. Performance regression detection** — nightly cron после
|
||||||
|
regression suite: prometheus p95 сравнение с вчерашним baseline.
|
||||||
|
Δ >30% → Telegram-alert.
|
||||||
|
- [ ] **7. Public-site analytics placeholder** — script tag в Astro
|
||||||
|
layout с `data-id="REPLACE_ME"`. docs/analytics.md с инструкцией.
|
||||||
|
|
||||||
|
## Журнал
|
||||||
|
|
||||||
|
### 2026-06-07 старт
|
||||||
|
Sprint 19 закрыт (7/7 ✓ + 1 hotfix). Начинаю tech debt + maintenance.
|
||||||
131
docs/sso.md
Normal file
131
docs/sso.md
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
# SSO — Google и Microsoft
|
||||||
|
|
||||||
|
Sprint 20: добавлен скелет SSO. Сейчас можно перейти на consent screen
|
||||||
|
у Google/Microsoft и получить email пользователя, но автоматического
|
||||||
|
создания учётной записи **нет** — это требует invite-flow от
|
||||||
|
администратора организации (см. multi-tenant ниже).
|
||||||
|
|
||||||
|
## Как получить keys
|
||||||
|
|
||||||
|
### Google
|
||||||
|
|
||||||
|
1. Перейти в [Google Cloud Console → APIs & Services → Credentials](https://console.cloud.google.com/apis/credentials).
|
||||||
|
2. **Create credentials → OAuth client ID**.
|
||||||
|
3. Application type: **Web application**.
|
||||||
|
4. **Authorized redirect URIs** — добавить:
|
||||||
|
- `https://admin.food-market.kz/signin-google` (prod)
|
||||||
|
- `https://test.admin.food-market.kz/signin-google` (stage)
|
||||||
|
- `http://localhost:5081/signin-google` (dev)
|
||||||
|
5. Скопировать **Client ID** и **Client secret**.
|
||||||
|
6. Положить в `appsettings.Production.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Authentication": {
|
||||||
|
"Google": {
|
||||||
|
"ClientId": "...",
|
||||||
|
"ClientSecret": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Microsoft
|
||||||
|
|
||||||
|
1. Перейти в [Azure Portal → App registrations](https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade) → **New registration**.
|
||||||
|
2. **Supported account types**: «Accounts in any organizational directory and personal Microsoft accounts».
|
||||||
|
3. **Redirect URI** (Web): добавить три URL аналогично Google (заменив `signin-google` → `signin-microsoft`).
|
||||||
|
4. После создания: **Certificates & secrets → New client secret**. Скопировать **value**.
|
||||||
|
5. **Overview → Application (client) ID**.
|
||||||
|
6. Конфиг:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Authentication": {
|
||||||
|
"Microsoft": {
|
||||||
|
"ClientId": "...",
|
||||||
|
"ClientSecret": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Как использовать
|
||||||
|
|
||||||
|
### Без настроенных keys
|
||||||
|
|
||||||
|
`GET /api/auth/external/google` → **503** с подсказкой:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "SSO для Google не настроено.",
|
||||||
|
"hint": "Добавьте в appsettings: Authentication:Google:ClientId и :ClientSecret. См. docs/sso.md."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /api/auth/external/providers` → текущее состояние:
|
||||||
|
```json
|
||||||
|
{ "google": false, "microsoft": false }
|
||||||
|
```
|
||||||
|
|
||||||
|
Web-фронт скрывает кнопки SSO когда оба провайдера = false.
|
||||||
|
|
||||||
|
### С настроенными keys
|
||||||
|
|
||||||
|
1. Пользователь жмёт «Войти через Google» → фронт делает редирект на
|
||||||
|
`GET /api/auth/external/google`.
|
||||||
|
2. Сервер возвращает 302 на Google consent screen.
|
||||||
|
3. После consent — Google редиректит на `/signin-google`, который
|
||||||
|
обрабатывает ASP.NET middleware и сохраняет identity во временный
|
||||||
|
cookie `fm.external`.
|
||||||
|
4. Middleware вызывает `/api/auth/external/callback?provider=google`.
|
||||||
|
5. **Sprint 20 scaffold**: callback возвращает **501** с информацией:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "scaffolded",
|
||||||
|
"message": "SSO-callback получен, но автоматическая регистрация ещё не реализована.",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"name": "John Doe",
|
||||||
|
"next": "Попросите администратора организации пригласить вас..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Что осталось доделать (после v1)
|
||||||
|
|
||||||
|
- **Invite-flow**: org-админ создаёт Employee запись с email'ом, после
|
||||||
|
чего SSO-callback находит этот email и линкует SSO-identity к существующему
|
||||||
|
User'у.
|
||||||
|
- **Выпуск OpenIddict access+refresh токенов** после успешного линка
|
||||||
|
(использовать тот же flow что и в `AuthController.PasswordGrant`).
|
||||||
|
- **Связь identity_provider+sub** для повторных логинов (новая таблица
|
||||||
|
`external_logins` (UserId, Provider, ProviderKey)).
|
||||||
|
- **UI**: на `/login` рендерить кнопки «Войти через Google/Microsoft»
|
||||||
|
только если `/api/auth/external/providers` вернул `true` для провайдера.
|
||||||
|
- **Конфликт email**: если пользователь с этим email уже есть и
|
||||||
|
привязан к другой org → отказ либо choice «выбрать org».
|
||||||
|
|
||||||
|
## Multi-tenant специфика
|
||||||
|
|
||||||
|
SSO **per-organization** — реальный пользователь в системе всегда
|
||||||
|
привязан к конкретной org через Employee. SSO-логин по email не
|
||||||
|
определяет org однозначно (один email может работать в нескольких
|
||||||
|
организациях через Employee-приглашения).
|
||||||
|
|
||||||
|
Поэтому invite-flow обязателен: SSO не создаёт User'а вслепую, а
|
||||||
|
лишь верифицирует «вы — владелец этого email» и линкует к ранее
|
||||||
|
приглашённому Employee.
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
Скелет тестируется без реальных keys:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 503 — провайдер не настроен
|
||||||
|
curl -i https://test.admin.food-market.kz/api/auth/external/google
|
||||||
|
|
||||||
|
# Список — все false
|
||||||
|
curl -s https://test.admin.food-market.kz/api/auth/external/providers
|
||||||
|
```
|
||||||
|
|
||||||
|
С реальными keys (нужно настроить в `appsettings.Stage.json`):
|
||||||
|
```bash
|
||||||
|
# Редирект на Google consent
|
||||||
|
curl -i 'https://test.admin.food-market.kz/api/auth/external/google?returnUrl=/dashboard'
|
||||||
|
```
|
||||||
87
src/food-market.api/Background/DatabaseMaintenanceJobs.cs
Normal file
87
src/food-market.api/Background/DatabaseMaintenanceJobs.cs
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Background;
|
||||||
|
|
||||||
|
/// <summary>Sprint 20: maintenance-операции на уровне БД.
|
||||||
|
///
|
||||||
|
/// <c>VACUUM ANALYZE</c> на топ-N таблиц по размеру (по умолчанию 5).
|
||||||
|
/// Без <c>FULL</c> — не блокирует пишущие транзакции, только конкурирует
|
||||||
|
/// за autovacuum-workers (что нормально). С <c>FULL</c> требует
|
||||||
|
/// ACCESS EXCLUSIVE lock — слишком жёстко для прода.
|
||||||
|
///
|
||||||
|
/// Pre-step: запрос к <c>pg_class</c> через <c>pg_total_relation_size</c>
|
||||||
|
/// чтобы определить топ-5 таблиц текущей БД. Это исключает hangfire/identity
|
||||||
|
/// таблицы по случайному маршруту (они автоматически попадут если реально
|
||||||
|
/// крупнее, что и есть желаемое поведение).
|
||||||
|
///
|
||||||
|
/// Idempotent: повторный запуск безопасен. Время выполнения логируется
|
||||||
|
/// per-table и в общую метрику <c>food_market_vacuum_duration_seconds</c>
|
||||||
|
/// (через Prometheus.Metrics, не реализовано в этой версии — TODO).</summary>
|
||||||
|
public class DatabaseMaintenanceJobs
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly IConfiguration _cfg;
|
||||||
|
private readonly ILogger<DatabaseMaintenanceJobs> _log;
|
||||||
|
|
||||||
|
public DatabaseMaintenanceJobs(AppDbContext db, IConfiguration cfg, ILogger<DatabaseMaintenanceJobs> log)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_cfg = cfg;
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record VacuumTableResult(string TableName, long SizeBytes, TimeSpan Duration);
|
||||||
|
public record VacuumRunResult(int TablesProcessed, TimeSpan TotalDuration, IReadOnlyList<VacuumTableResult> Details);
|
||||||
|
|
||||||
|
/// <summary>Запускает VACUUM ANALYZE на топ-N таблиц публичной схемы
|
||||||
|
/// по размеру (с индексами). Возвращает детали для логирования /
|
||||||
|
/// диагностики /admin/diagnostic.</summary>
|
||||||
|
public async Task<VacuumRunResult> VacuumTopTablesAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var topN = _cfg.GetValue("Maintenance:VacuumTopN", 5);
|
||||||
|
|
||||||
|
// Список таблиц + размер. relname — имя таблицы в схеме public.
|
||||||
|
// pg_total_relation_size включает индексы + TOAST.
|
||||||
|
var tables = await _db.Database.SqlQuery<TableSize>($@"
|
||||||
|
SELECT relname AS ""TableName"",
|
||||||
|
pg_total_relation_size(C.oid) AS ""SizeBytes""
|
||||||
|
FROM pg_class C
|
||||||
|
LEFT JOIN pg_namespace N ON N.oid = C.relnamespace
|
||||||
|
WHERE C.relkind = 'r'
|
||||||
|
AND N.nspname = 'public'
|
||||||
|
ORDER BY pg_total_relation_size(C.oid) DESC
|
||||||
|
LIMIT {topN}").ToListAsync(ct);
|
||||||
|
|
||||||
|
var details = new List<VacuumTableResult>();
|
||||||
|
var totalSw = Stopwatch.StartNew();
|
||||||
|
foreach (var t in tables)
|
||||||
|
{
|
||||||
|
// Имя таблицы — нельзя параметризовать в DDL, но мы взяли его из
|
||||||
|
// pg_class фильтром relkind='r' + nspname='public', поэтому
|
||||||
|
// SQL-injection невозможна (только реально существующие таблицы).
|
||||||
|
var safe = t.TableName.Replace("\"", "");
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _db.Database.ExecuteSqlRawAsync($"VACUUM (ANALYZE) public.\"{safe}\"", ct);
|
||||||
|
sw.Stop();
|
||||||
|
details.Add(new VacuumTableResult(safe, t.SizeBytes, sw.Elapsed));
|
||||||
|
_log.LogInformation("Hangfire/VacuumTopTables: VACUUM ANALYZE {Table} ({SizeBytes} bytes) → {Ms}ms",
|
||||||
|
safe, t.SizeBytes, sw.ElapsedMilliseconds);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
sw.Stop();
|
||||||
|
_log.LogWarning(ex, "Hangfire/VacuumTopTables: ошибка на {Table} (skip): {Msg}", safe, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
totalSw.Stop();
|
||||||
|
_log.LogInformation("Hangfire/VacuumTopTables: всего {Count} таблиц, время {Ms}ms",
|
||||||
|
details.Count, totalSw.ElapsedMilliseconds);
|
||||||
|
return new VacuumRunResult(details.Count, totalSw.Elapsed, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record TableSize(string TableName, long SizeBytes);
|
||||||
|
}
|
||||||
105
src/food-market.api/Background/DiskMonitoringJob.cs
Normal file
105
src/food-market.api/Background/DiskMonitoringJob.cs
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
using foodmarket.Api.Infrastructure.Observability;
|
||||||
|
using foodmarket.Api.Integrations.Telegram;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Background;
|
||||||
|
|
||||||
|
/// <summary>Sprint 20: ежечасный мониторинг свободного места на основных
|
||||||
|
/// mount-points.
|
||||||
|
///
|
||||||
|
/// Считывает <c>DriveInfo.AvailableFreeSpace</c> для каждого пути из
|
||||||
|
/// конфига <c>Monitoring:DiskPaths</c> (дефолт: "/opt,/var/lib/docker").
|
||||||
|
/// Обновляет gauge <c>food_market_disk_free_bytes{mount="..."}</c>.
|
||||||
|
/// При свободном месте < <c>Monitoring:DiskMinFreeBytes</c> (дефолт 1GB)
|
||||||
|
/// шлёт Telegram-alert каждому chat'у из <c>Monitoring:SuperAdminTelegramChatIds</c>
|
||||||
|
/// (CSV формат: "123,456").
|
||||||
|
///
|
||||||
|
/// Anti-spam: alert повторяется не чаще раза в 6 часов на (mount).
|
||||||
|
/// Состояние хранится in-memory — рестарт сервиса обнуляет (приемлемо).</summary>
|
||||||
|
public class DiskMonitoringJob
|
||||||
|
{
|
||||||
|
private readonly IConfiguration _cfg;
|
||||||
|
private readonly ITelegramBotClient _tg;
|
||||||
|
private readonly ILogger<DiskMonitoringJob> _log;
|
||||||
|
// Anti-spam: последние времена alert'ов per mount.
|
||||||
|
private static readonly Dictionary<string, DateTime> _lastAlertAt = new();
|
||||||
|
private static readonly object _lock = new();
|
||||||
|
|
||||||
|
public DiskMonitoringJob(IConfiguration cfg, ITelegramBotClient tg, ILogger<DiskMonitoringJob> log)
|
||||||
|
{
|
||||||
|
_cfg = cfg;
|
||||||
|
_tg = tg;
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CheckAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var pathsCsv = _cfg["Monitoring:DiskPaths"] ?? "/opt,/var/lib/docker";
|
||||||
|
var minFreeBytes = _cfg.GetValue<long>("Monitoring:DiskMinFreeBytes", 1L * 1024 * 1024 * 1024);
|
||||||
|
var alertCooldownHours = _cfg.GetValue("Monitoring:DiskAlertCooldownHours", 6);
|
||||||
|
var chatsCsv = _cfg["Monitoring:SuperAdminTelegramChatIds"] ?? "";
|
||||||
|
|
||||||
|
var paths = pathsCsv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
var chats = chatsCsv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Select(s => long.TryParse(s, out var n) ? n : 0L)
|
||||||
|
.Where(n => n > 0).ToList();
|
||||||
|
|
||||||
|
foreach (var path in paths)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// DriveInfo требует существующий путь; в контейнере /opt может
|
||||||
|
// не быть смонтирован (тогда логируем warn и пропускаем).
|
||||||
|
if (!Directory.Exists(path))
|
||||||
|
{
|
||||||
|
_log.LogDebug("DiskMonitor: path {Path} not mounted, skip", path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var di = new DriveInfo(path);
|
||||||
|
var free = di.AvailableFreeSpace;
|
||||||
|
AppMetrics.DiskFreeBytes.WithLabels(path).Set(free);
|
||||||
|
_log.LogDebug("DiskMonitor: {Path} free={FreeMB}MB", path, free / 1024 / 1024);
|
||||||
|
|
||||||
|
if (free < minFreeBytes)
|
||||||
|
{
|
||||||
|
var allowed = ShouldAlert(path, TimeSpan.FromHours(alertCooldownHours));
|
||||||
|
if (allowed && chats.Count > 0 && _tg.IsEnabled)
|
||||||
|
{
|
||||||
|
var freeMb = free / 1024 / 1024;
|
||||||
|
var minMb = minFreeBytes / 1024 / 1024;
|
||||||
|
var msg = $"⚠️ Low disk space on {path}: {freeMb} MB free (threshold {minMb} MB).";
|
||||||
|
foreach (var chat in chats)
|
||||||
|
{
|
||||||
|
await _tg.SendMessageAsync(chat, msg, ct);
|
||||||
|
}
|
||||||
|
_log.LogWarning("DiskMonitor: alert sent — {Path} {Free}MB < {Min}MB",
|
||||||
|
path, freeMb, minMb);
|
||||||
|
}
|
||||||
|
else if (!allowed)
|
||||||
|
{
|
||||||
|
_log.LogDebug("DiskMonitor: low disk on {Path} but cooldown active", path);
|
||||||
|
}
|
||||||
|
else if (chats.Count == 0)
|
||||||
|
{
|
||||||
|
_log.LogWarning("DiskMonitor: low disk on {Path} {Free}B but no SuperAdmin chats configured",
|
||||||
|
path, free);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogWarning(ex, "DiskMonitor: ошибка на {Path}: {Msg}", path, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldAlert(string mount, TimeSpan cooldown)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (_lastAlertAt.TryGetValue(mount, out var last) && DateTime.UtcNow - last < cooldown)
|
||||||
|
return false;
|
||||||
|
_lastAlertAt[mount] = DateTime.UtcNow;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -39,6 +39,46 @@ public Task StartAsync(CancellationToken ct)
|
||||||
cronExpression: cronAudit,
|
cronExpression: cronAudit,
|
||||||
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
|
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
|
||||||
|
|
||||||
|
// Sprint 20: per-tenant audit-log + drafts + revoked refresh-tokens.
|
||||||
|
// Default 03:00 UTC group (можно переопределить через конфиг).
|
||||||
|
var cronOrgAudit = _cfg["Hangfire:Cron:PruneOrgAuditLog"] ?? "0 3 * * *";
|
||||||
|
var cronDrafts = _cfg["Hangfire:Cron:PruneDrafts"] ?? "15 3 * * *";
|
||||||
|
var cronTokens = _cfg["Hangfire:Cron:PruneRevokedRefreshTokens"] ?? "20 3 * * *";
|
||||||
|
|
||||||
|
_jobs.AddOrUpdate<HousekeepingJobs>(
|
||||||
|
recurringJobId: "prune-org-audit-log",
|
||||||
|
methodCall: j => j.PruneOrgAuditLogAsync(CancellationToken.None),
|
||||||
|
cronExpression: cronOrgAudit,
|
||||||
|
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
|
||||||
|
|
||||||
|
_jobs.AddOrUpdate<HousekeepingJobs>(
|
||||||
|
recurringJobId: "prune-drafts",
|
||||||
|
methodCall: j => j.PruneDraftsAsync(CancellationToken.None),
|
||||||
|
cronExpression: cronDrafts,
|
||||||
|
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
|
||||||
|
|
||||||
|
_jobs.AddOrUpdate<HousekeepingJobs>(
|
||||||
|
recurringJobId: "prune-revoked-refresh-tokens",
|
||||||
|
methodCall: j => j.PruneRevokedRefreshTokensAsync(CancellationToken.None),
|
||||||
|
cronExpression: cronTokens,
|
||||||
|
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
|
||||||
|
|
||||||
|
// Sprint 20: weekly VACUUM ANALYZE топ-5 таблиц + ежечасный disk-monitor.
|
||||||
|
var cronVacuum = _cfg["Hangfire:Cron:VacuumTopTables"] ?? "0 4 * * 0"; // Воскресенье 04:00 UTC
|
||||||
|
var cronDisk = _cfg["Hangfire:Cron:DiskMonitor"] ?? "0 * * * *"; // каждый час
|
||||||
|
|
||||||
|
_jobs.AddOrUpdate<DatabaseMaintenanceJobs>(
|
||||||
|
recurringJobId: "vacuum-top-tables",
|
||||||
|
methodCall: j => j.VacuumTopTablesAsync(CancellationToken.None),
|
||||||
|
cronExpression: cronVacuum,
|
||||||
|
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
|
||||||
|
|
||||||
|
_jobs.AddOrUpdate<DiskMonitoringJob>(
|
||||||
|
recurringJobId: "disk-monitor",
|
||||||
|
methodCall: j => j.CheckAsync(CancellationToken.None),
|
||||||
|
cronExpression: cronDisk,
|
||||||
|
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
|
||||||
|
|
||||||
// Email-уведомления: weekly-summary в понедельник 07:00 UTC,
|
// Email-уведомления: weekly-summary в понедельник 07:00 UTC,
|
||||||
// low-stock каждый день в 08:00 UTC (после weekly чтобы не дублить
|
// low-stock каждый день в 08:00 UTC (после weekly чтобы не дублить
|
||||||
// если оба совпадают). Cron-ы конфигурируются — на тестовом стенде
|
// если оба совпадают). Cron-ы конфигурируются — на тестовом стенде
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
using foodmarket.Domain.Inventory;
|
using foodmarket.Domain.Inventory;
|
||||||
|
using foodmarket.Domain.Purchases;
|
||||||
|
using foodmarket.Domain.Sales;
|
||||||
using foodmarket.Infrastructure.Persistence;
|
using foodmarket.Infrastructure.Persistence;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
|
@ -61,4 +63,75 @@ public async Task<int> PruneAuditLogAsync(CancellationToken ct = default)
|
||||||
deleted, threshold);
|
deleted, threshold);
|
||||||
return deleted;
|
return deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Sprint 20: удаляет <c>OrgAuditLog</c> старше N дней
|
||||||
|
/// (по умолчанию 90). Per-tenant журнал мутаций отчётности — растёт
|
||||||
|
/// быстрее SuperAdmin-аудита, поэтому отдельный prune.</summary>
|
||||||
|
public async Task<int> PruneOrgAuditLogAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var days = _cfg.GetValue("Cleanup:OrgAuditLogDays", 90);
|
||||||
|
var threshold = DateTime.UtcNow.AddDays(-days);
|
||||||
|
|
||||||
|
var deleted = await _db.OrgAuditLogs
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(a => a.CreatedAt < threshold)
|
||||||
|
.ExecuteDeleteAsync(ct);
|
||||||
|
_log.LogInformation("Hangfire/PruneOrgAuditLog: удалено {Count} записей старше {Threshold:O}",
|
||||||
|
deleted, threshold);
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sprint 20: удаляет draft-документы старше N дней (по умолчанию 30).
|
||||||
|
/// Что чистим: Supply / RetailSale / Demand в статусе Draft, без PostedAt.
|
||||||
|
/// Каскадно зацепляются связанные lines (FK ON DELETE CASCADE — настроено
|
||||||
|
/// в configurations).
|
||||||
|
///
|
||||||
|
/// Безопасность: draft = пользователь его не провёл, движений не было,
|
||||||
|
/// удаление не нарушает Stock-инвариант. Per-tenant фильтр НЕ применяется
|
||||||
|
/// (cross-tenant cleanup), но в WHERE добавляется проверка status.</summary>
|
||||||
|
public async Task<(int Supplies, int Sales, int Demands)> PruneDraftsAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var days = _cfg.GetValue("Cleanup:DraftDays", 30);
|
||||||
|
var threshold = DateTime.UtcNow.AddDays(-days);
|
||||||
|
|
||||||
|
var supplies = await _db.Supplies.IgnoreQueryFilters()
|
||||||
|
.Where(s => s.Status == SupplyStatus.Draft && s.CreatedAt < threshold)
|
||||||
|
.ExecuteDeleteAsync(ct);
|
||||||
|
var sales = await _db.RetailSales.IgnoreQueryFilters()
|
||||||
|
.Where(s => s.Status == RetailSaleStatus.Draft && s.CreatedAt < threshold)
|
||||||
|
.ExecuteDeleteAsync(ct);
|
||||||
|
var demands = await _db.Demands.IgnoreQueryFilters()
|
||||||
|
.Where(d => d.Status == DemandStatus.Draft && d.CreatedAt < threshold)
|
||||||
|
.ExecuteDeleteAsync(ct);
|
||||||
|
|
||||||
|
_log.LogInformation("Hangfire/PruneDrafts: supplies={S} sales={Sa} demands={D} (старше {Threshold:O})",
|
||||||
|
supplies, sales, demands, threshold);
|
||||||
|
return (supplies, sales, demands);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sprint 20: удаляет refresh-токены OpenIddict со статусом
|
||||||
|
/// revoked старше N дней (по умолчанию 7). Активные / неревокированные
|
||||||
|
/// не трогаем — у них собственный TTL.
|
||||||
|
///
|
||||||
|
/// OpenIddict таблица — <c>OpenIddictTokens</c>. Status = "revoked" |
|
||||||
|
/// "redeemed" | "valid". Type = "refresh_token" | "access_token".
|
||||||
|
/// Удаляем только refresh-токены: access-токены протухают быстро (15
|
||||||
|
/// минут), их Hangfire-cleanup'a в OpenIddict уже есть.</summary>
|
||||||
|
public async Task<int> PruneRevokedRefreshTokensAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var days = _cfg.GetValue("Cleanup:RevokedRefreshTokenDays", 7);
|
||||||
|
var threshold = DateTime.UtcNow.AddDays(-days);
|
||||||
|
|
||||||
|
// OpenIddict ED таблица: тип `OpenIddictEntityFrameworkCoreToken`.
|
||||||
|
// Используем raw SQL чтобы не тащить лишних using-ов; колонки
|
||||||
|
// прибиты конвенцией OpenIddict.
|
||||||
|
var deleted = await _db.Database.ExecuteSqlInterpolatedAsync($@"
|
||||||
|
DELETE FROM ""OpenIddictTokens""
|
||||||
|
WHERE ""Type"" = 'refresh_token'
|
||||||
|
AND ""Status"" IN ('revoked', 'redeemed')
|
||||||
|
AND ""CreationDate"" < {threshold}", ct);
|
||||||
|
_log.LogInformation("Hangfire/PruneRevokedRefreshTokens: удалено {Count} токенов старше {Threshold:O}",
|
||||||
|
deleted, threshold);
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
127
src/food-market.api/Controllers/Auth/ExternalAuthController.cs
Normal file
127
src/food-market.api/Controllers/Auth/ExternalAuthController.cs
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Controllers.Auth;
|
||||||
|
|
||||||
|
/// <summary>Sprint 20: SSO scaffolding для Google + Microsoft.
|
||||||
|
///
|
||||||
|
/// Endpoint <c>/api/auth/external/{provider}</c> инициирует OAuth-челлендж:
|
||||||
|
/// - провайдер не задан в конфиге → 503 с подсказкой админу
|
||||||
|
/// - провайдер задан → редирект на Google/Microsoft consent screen
|
||||||
|
///
|
||||||
|
/// Callback <c>/signin-google</c> / <c>/signin-microsoft</c> обрабатывается
|
||||||
|
/// AspNetCore middleware'ом (см. AddGoogle/AddMicrosoftAccount в Program.cs);
|
||||||
|
/// после успешного OAuth-обмена пользователь попадает в
|
||||||
|
/// <c>/api/auth/external/callback?provider=...&returnUrl=...</c>, где
|
||||||
|
/// мы:
|
||||||
|
/// 1. Читаем claims (email + name)
|
||||||
|
/// 2. Ищем существующего User по email; если нет — создаём
|
||||||
|
/// 3. Создаём Employee запись (привязка к Org через какую-то логику —
|
||||||
|
/// в текущем скелете НЕ выбрана: SSO для multi-tenant требует
|
||||||
|
/// «invite»-flow со ссылкой на конкретную org, что выходит за рамки)
|
||||||
|
/// 4. Возвращаем 501 с ответом «требуется invite от орг-админа»
|
||||||
|
///
|
||||||
|
/// На v1 решено: SSO работает только для существующих User'ов
|
||||||
|
/// (email match), без auto-create. Это даёт безопасный fallback и
|
||||||
|
/// позволяет владельцу orgа сначала пригласить сотрудника штатно.</summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/auth/external")]
|
||||||
|
public class ExternalAuthController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IConfiguration _cfg;
|
||||||
|
private readonly ILogger<ExternalAuthController> _log;
|
||||||
|
|
||||||
|
public ExternalAuthController(IConfiguration cfg, ILogger<ExternalAuthController> log)
|
||||||
|
{
|
||||||
|
_cfg = cfg;
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Инициирует OAuth challenge на провайдере. Если провайдер
|
||||||
|
/// не сконфигурирован — 503 с подсказкой.</summary>
|
||||||
|
[HttpGet("{provider}")]
|
||||||
|
public IActionResult Challenge(string provider, [FromQuery] string? returnUrl = null)
|
||||||
|
{
|
||||||
|
var scheme = provider?.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"google" => "Google",
|
||||||
|
"microsoft" => "Microsoft",
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
if (scheme is null)
|
||||||
|
return BadRequest(new { error = $"Неизвестный provider: {provider}. Допустимы: google, microsoft." });
|
||||||
|
|
||||||
|
// Проверяем конфиг
|
||||||
|
var configured = scheme == "Google"
|
||||||
|
? !string.IsNullOrEmpty(_cfg["Authentication:Google:ClientId"])
|
||||||
|
: !string.IsNullOrEmpty(_cfg["Authentication:Microsoft:ClientId"]);
|
||||||
|
if (!configured)
|
||||||
|
{
|
||||||
|
return StatusCode(503, new
|
||||||
|
{
|
||||||
|
error = $"SSO для {scheme} не настроено.",
|
||||||
|
hint = $"Добавьте в appsettings: Authentication:{scheme}:ClientId и :ClientSecret. См. docs/sso.md.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var props = new AuthenticationProperties
|
||||||
|
{
|
||||||
|
RedirectUri = Url.Action(nameof(Callback), new { provider = scheme.ToLowerInvariant(), returnUrl }),
|
||||||
|
};
|
||||||
|
return Challenge(props, scheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Callback после успешного OAuth у провайдера. Читает claims
|
||||||
|
/// и решает, что делать: связать с существующим User или вернуть
|
||||||
|
/// «пользователь не найден, попросите админа пригласить».</summary>
|
||||||
|
[HttpGet("callback")]
|
||||||
|
public async Task<IActionResult> Callback([FromQuery] string provider, [FromQuery] string? returnUrl = null)
|
||||||
|
{
|
||||||
|
var auth = await HttpContext.AuthenticateAsync("ExternalSignIn");
|
||||||
|
if (auth?.Principal is null)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "OAuth-callback не содержит principal'a. Попробуйте ещё раз." });
|
||||||
|
}
|
||||||
|
var email = auth.Principal.FindFirstValue(ClaimTypes.Email)
|
||||||
|
?? auth.Principal.FindFirstValue("email");
|
||||||
|
var name = auth.Principal.FindFirstValue(ClaimTypes.Name)
|
||||||
|
?? auth.Principal.FindFirstValue("name");
|
||||||
|
if (string.IsNullOrEmpty(email))
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Провайдер не вернул email. Проверьте scope в конфиге." });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sprint 20 scaffold: не выпускаем токены, не создаём пользователей.
|
||||||
|
// Реальный multi-tenant SSO требует invite-flow: org-админ создаёт
|
||||||
|
// Employee запись с email'ом, и тогда callback связывает SSO-identity
|
||||||
|
// с этим Employee. Без invite — отказываем (для безопасности).
|
||||||
|
_log.LogInformation("External auth callback: provider={Provider} email={Email} name={Name}",
|
||||||
|
provider, email, name);
|
||||||
|
|
||||||
|
// Sign-out from ExternalSignIn cookie, чтобы не висели stale-данные.
|
||||||
|
await HttpContext.SignOutAsync("ExternalSignIn");
|
||||||
|
|
||||||
|
return StatusCode(501, new
|
||||||
|
{
|
||||||
|
status = "scaffolded",
|
||||||
|
message = "SSO-callback получен, но автоматическая регистрация ещё не реализована (Sprint 20 scaffold).",
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
provider,
|
||||||
|
next = "Попросите администратора организации пригласить вас (Settings → Employees → Invite) и используйте обычный email-логин.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Список доступных SSO-провайдеров. Web-фронт по этому
|
||||||
|
/// списку решает, какие кнопки рисовать на /login.</summary>
|
||||||
|
[HttpGet("providers")]
|
||||||
|
public IActionResult Providers()
|
||||||
|
{
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
google = !string.IsNullOrEmpty(_cfg["Authentication:Google:ClientId"]),
|
||||||
|
microsoft = !string.IsNullOrEmpty(_cfg["Authentication:Microsoft:ClientId"]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
using foodmarket.Infrastructure.Persistence;
|
using foodmarket.Infrastructure.Persistence;
|
||||||
using foodmarket.Api.Infrastructure.Authorization;
|
using foodmarket.Api.Infrastructure.Authorization;
|
||||||
using foodmarket.Api.Controllers.Reports;
|
using foodmarket.Api.Controllers.Reports;
|
||||||
|
using foodmarket.Application.Mapping;
|
||||||
|
using Mapster;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
@ -49,13 +51,10 @@ public class CounterpartiesController : ControllerBase
|
||||||
("name", true) => q.OrderByDescending(c => c.Name),
|
("name", true) => q.OrderByDescending(c => c.Name),
|
||||||
_ => q.OrderBy(c => c.Name),
|
_ => q.OrderBy(c => c.Name),
|
||||||
};
|
};
|
||||||
|
// Sprint 20 / TD-3: ProjectToType вместо ручного Select.
|
||||||
var items = await q
|
var items = await q
|
||||||
.Skip(req.Skip).Take(req.Take)
|
.Skip(req.Skip).Take(req.Take)
|
||||||
.Select(c => new CounterpartyDto(
|
.ProjectToType<CounterpartyDto>(MapsterConfig.Config)
|
||||||
c.Id, c.Name, c.LegalName, c.Type,
|
|
||||||
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country != null ? c.Country.Name : null,
|
|
||||||
c.Address, c.Phone, c.Email,
|
|
||||||
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes))
|
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
return new PagedResult<CounterpartyDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
return new PagedResult<CounterpartyDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||||
}
|
}
|
||||||
|
|
@ -87,12 +86,12 @@ public record ExportRow(string Name, string? LegalName, string Type, string? Bin
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
public async Task<ActionResult<CounterpartyDto>> Get(Guid id, CancellationToken ct)
|
public async Task<ActionResult<CounterpartyDto>> Get(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var c = await _db.Counterparties.Include(x => x.Country).AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
// Sprint 20 / TD-3: ProjectToType — Mapster выберет только нужные
|
||||||
return c is null ? NotFound() : new CounterpartyDto(
|
// поля (включая Country.Name), без Include на полный entity.
|
||||||
c.Id, c.Name, c.LegalName, c.Type,
|
var dto = await _db.Counterparties.AsNoTracking().Where(x => x.Id == id)
|
||||||
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name,
|
.ProjectToType<CounterpartyDto>(MapsterConfig.Config)
|
||||||
c.Address, c.Phone, c.Email,
|
.FirstOrDefaultAsync(ct);
|
||||||
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes);
|
return dto is null ? NotFound() : dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost, RequiresPermission("CounterpartiesEdit")]
|
[HttpPost, RequiresPermission("CounterpartiesEdit")]
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
using foodmarket.Infrastructure.Persistence;
|
using foodmarket.Infrastructure.Persistence;
|
||||||
using foodmarket.Api.Infrastructure.Authorization;
|
using foodmarket.Api.Infrastructure.Authorization;
|
||||||
using foodmarket.Api.Controllers.Reports;
|
using foodmarket.Api.Controllers.Reports;
|
||||||
|
using foodmarket.Application.Mapping;
|
||||||
|
using Mapster;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
@ -196,9 +198,12 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
|
||||||
("name", true) => q.OrderByDescending(p => p.Name),
|
("name", true) => q.OrderByDescending(p => p.Name),
|
||||||
_ => q.OrderBy(p => p.Name),
|
_ => q.OrderBy(p => p.Name),
|
||||||
};
|
};
|
||||||
|
// Sprint 20 / TD-3: ProjectToType<ProductDto>() через Mapster.
|
||||||
|
// Эквивалент `.Select(Projection)` по SQL, но проекция централизована
|
||||||
|
// в MapsterConfig — добавление новой DTO больше не правит контроллер.
|
||||||
var items = await q
|
var items = await q
|
||||||
.Skip(req.Skip).Take(req.Take)
|
.Skip(req.Skip).Take(req.Take)
|
||||||
.Select(Projection)
|
.ProjectToType<ProductDto>(MapsterConfig.Config)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
return new PagedResult<ProductDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
return new PagedResult<ProductDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||||
}
|
}
|
||||||
|
|
@ -254,7 +259,9 @@ public record ExportRow(
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
public async Task<ActionResult<ProductDto>> Get(Guid id, CancellationToken ct)
|
public async Task<ActionResult<ProductDto>> Get(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var p = await QueryIncludes().AsNoTracking().Where(x => x.Id == id).Select(Projection).FirstOrDefaultAsync(ct);
|
var p = await QueryIncludes().AsNoTracking().Where(x => x.Id == id)
|
||||||
|
.ProjectToType<ProductDto>(MapsterConfig.Config)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
return p is null ? NotFound() : p;
|
return p is null ? NotFound() : p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -956,25 +963,12 @@ public record ByBarcodeResult(IReadOnlyList<QuickSearchItem> Items);
|
||||||
.Include(p => p.Prices).ThenInclude(pr => pr.Currency);
|
.Include(p => p.Prices).ThenInclude(pr => pr.Currency);
|
||||||
|
|
||||||
private async Task<ProductDto> GetInternalAsync(Guid id, CancellationToken ct) =>
|
private async Task<ProductDto> GetInternalAsync(Guid id, CancellationToken ct) =>
|
||||||
await QueryIncludes().AsNoTracking().Where(x => x.Id == id).Select(Projection).FirstAsync(ct);
|
await QueryIncludes().AsNoTracking().Where(x => x.Id == id)
|
||||||
|
.ProjectToType<ProductDto>(MapsterConfig.Config).FirstAsync(ct);
|
||||||
|
|
||||||
private static readonly System.Linq.Expressions.Expression<Func<Product, ProductDto>> Projection = p =>
|
// Sprint 20 / TD-3: ручная Projection-Expression удалена. Маппинг
|
||||||
new ProductDto(
|
// Product → ProductDto живёт в `MapsterConfig.Build()` и применяется
|
||||||
p.Id, p.Name, p.Article, p.Description,
|
// через `.ProjectToType<ProductDto>(MapsterConfig.Config)`.
|
||||||
p.UnitOfMeasureId, p.UnitOfMeasure!.Name,
|
|
||||||
p.Vat, p.VatEnabled,
|
|
||||||
p.ProductGroupId, p.ProductGroup!.Name,
|
|
||||||
p.DefaultSupplierId, p.DefaultSupplier != null ? p.DefaultSupplier.Name : null,
|
|
||||||
p.CountryOfOriginId, p.CountryOfOrigin != null ? p.CountryOfOrigin.Name : null,
|
|
||||||
p.IsService, p.Packaging, p.IsMarked,
|
|
||||||
p.MinStock, p.MaxStock,
|
|
||||||
p.ReferencePrice, p.ReferencePriceUpdatedAt,
|
|
||||||
p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null,
|
|
||||||
p.Cost, p.LastSupplyAt,
|
|
||||||
p.ImageUrl,
|
|
||||||
p.IsArchived, p.IsAvailableForSale,
|
|
||||||
p.Prices.Select(pr => new ProductPriceDto(pr.Id, pr.PriceTypeId, pr.PriceType!.Name, pr.Amount, pr.CurrencyId, pr.Currency!.Code)).ToList(),
|
|
||||||
p.Barcodes.Select(b => new ProductBarcodeDto(b.Id, b.Code, b.Type, b.IsPrimary)).ToList());
|
|
||||||
|
|
||||||
private static void Apply(Product e, ProductInput i)
|
private static void Apply(Product e, ProductInput i)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -70,4 +70,12 @@ public static void IncrementPosted(string type)
|
||||||
|
|
||||||
public static void IncrementError(string type, string reason)
|
public static void IncrementError(string type, string reason)
|
||||||
=> DocumentsError.WithLabels(type, reason).Inc();
|
=> DocumentsError.WithLabels(type, reason).Inc();
|
||||||
|
|
||||||
|
/// <summary>Sprint 20: disk free bytes per mount point. Обновляется
|
||||||
|
/// почасовым Hangfire job'ом <c>DiskMonitoringJob</c>. Метка <c>mount</c>
|
||||||
|
/// — путь монтирования ("/opt", "/var/lib/docker").</summary>
|
||||||
|
public static readonly Gauge DiskFreeBytes = Metrics.CreateGauge(
|
||||||
|
"food_market_disk_free_bytes",
|
||||||
|
"Свободное место на mount-point в байтах (обновляется почасовым Hangfire job'ом).",
|
||||||
|
new GaugeConfiguration { LabelNames = new[] { "mount" } });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,13 @@
|
||||||
|
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
builder.Services.AddScoped<ITenantContext, HttpContextTenantContext>();
|
builder.Services.AddScoped<ITenantContext, HttpContextTenantContext>();
|
||||||
|
|
||||||
|
// Sprint 20 / TD-3: Mapster config — singleton TypeAdapterConfig.
|
||||||
|
// Используется в контроллерах через ProjectToType<TDto>(MapsterConfig.Config)
|
||||||
|
// для SQL-friendly проекций. IMapper в DI не регистрируем, потому что
|
||||||
|
// ProjectToType<T>() работает напрямую с config'ом — это эквивалент
|
||||||
|
// ручного `Select(...)` без runtime-reflection.
|
||||||
|
builder.Services.AddSingleton(foodmarket.Application.Mapping.MapsterConfig.Config);
|
||||||
// ClaimsTransformation для SuperAdmin override: добавляет роли Admin/
|
// ClaimsTransformation для SuperAdmin override: добавляет роли Admin/
|
||||||
// Storekeeper/Cashier при наличии X-Org-Override, чтобы [Authorize(Roles=...)]
|
// Storekeeper/Cashier при наличии X-Org-Override, чтобы [Authorize(Roles=...)]
|
||||||
// не отшивал edit-mode мутации SuperAdmin'а. См. SuperAdminOverrideClaimsTransformer.
|
// не отшивал edit-mode мутации SuperAdmin'а. См. SuperAdminOverrideClaimsTransformer.
|
||||||
|
|
@ -155,12 +162,54 @@ static string ApplyDefaultPoolConfig(string? raw)
|
||||||
|
|
||||||
// Force OpenIddict validation to handle ALL [Authorize] challenges — otherwise AddIdentity's
|
// Force OpenIddict validation to handle ALL [Authorize] challenges — otherwise AddIdentity's
|
||||||
// cookie scheme takes over and 401 becomes a 302 redirect to /Account/Login for API calls.
|
// cookie scheme takes over and 401 becomes a 302 redirect to /Account/Login for API calls.
|
||||||
builder.Services.AddAuthentication(options =>
|
var authBuilder = builder.Services.AddAuthentication(options =>
|
||||||
{
|
{
|
||||||
options.DefaultScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
|
options.DefaultScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
|
||||||
options.DefaultAuthenticateScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
|
options.DefaultAuthenticateScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
|
||||||
options.DefaultChallengeScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
|
options.DefaultChallengeScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sprint 20: SSO scaffolding. Внешние providers подключаются только когда
|
||||||
|
// в конфиге задан ClientId — иначе их не регистрируем (контроллер вернёт
|
||||||
|
// 503 на /api/auth/external/{provider}). Cookie-схема нужна для временного
|
||||||
|
// хранения OAuth-стейта между challenge и callback'ом.
|
||||||
|
var googleId = builder.Configuration["Authentication:Google:ClientId"];
|
||||||
|
var googleSecret = builder.Configuration["Authentication:Google:ClientSecret"];
|
||||||
|
var msId = builder.Configuration["Authentication:Microsoft:ClientId"];
|
||||||
|
var msSecret = builder.Configuration["Authentication:Microsoft:ClientSecret"];
|
||||||
|
var anySso = !string.IsNullOrEmpty(googleId) || !string.IsNullOrEmpty(msId);
|
||||||
|
if (anySso)
|
||||||
|
{
|
||||||
|
authBuilder.AddCookie("ExternalSignIn", o =>
|
||||||
|
{
|
||||||
|
o.Cookie.Name = "fm.external";
|
||||||
|
o.Cookie.HttpOnly = true;
|
||||||
|
o.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax;
|
||||||
|
o.ExpireTimeSpan = TimeSpan.FromMinutes(10);
|
||||||
|
});
|
||||||
|
if (!string.IsNullOrEmpty(googleId) && !string.IsNullOrEmpty(googleSecret))
|
||||||
|
{
|
||||||
|
authBuilder.AddGoogle("Google", o =>
|
||||||
|
{
|
||||||
|
o.ClientId = googleId;
|
||||||
|
o.ClientSecret = googleSecret;
|
||||||
|
o.SignInScheme = "ExternalSignIn";
|
||||||
|
o.CallbackPath = "/signin-google";
|
||||||
|
o.SaveTokens = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrEmpty(msId) && !string.IsNullOrEmpty(msSecret))
|
||||||
|
{
|
||||||
|
authBuilder.AddMicrosoftAccount("Microsoft", o =>
|
||||||
|
{
|
||||||
|
o.ClientId = msId;
|
||||||
|
o.ClientSecret = msSecret;
|
||||||
|
o.SignInScheme = "ExternalSignIn";
|
||||||
|
o.CallbackPath = "/signin-microsoft";
|
||||||
|
o.SaveTokens = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
builder.Services.AddAuthorization(opts =>
|
builder.Services.AddAuthorization(opts =>
|
||||||
{
|
{
|
||||||
// Check the "role" claim explicitly — robust against role-claim-type mismatches between
|
// Check the "role" claim explicitly — robust against role-claim-type mismatches between
|
||||||
|
|
@ -358,6 +407,9 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
||||||
builder.Services.AddHostedService<foodmarket.Api.Background.HangfireGlobalFilterRegistrar>();
|
builder.Services.AddHostedService<foodmarket.Api.Background.HangfireGlobalFilterRegistrar>();
|
||||||
}
|
}
|
||||||
builder.Services.AddScoped<foodmarket.Api.Background.HousekeepingJobs>();
|
builder.Services.AddScoped<foodmarket.Api.Background.HousekeepingJobs>();
|
||||||
|
// Sprint 20: DB VACUUM ANALYZE + disk monitoring.
|
||||||
|
builder.Services.AddScoped<foodmarket.Api.Background.DatabaseMaintenanceJobs>();
|
||||||
|
builder.Services.AddScoped<foodmarket.Api.Background.DiskMonitoringJob>();
|
||||||
builder.Services.AddScoped<foodmarket.Api.Background.EmailNotificationJobs>();
|
builder.Services.AddScoped<foodmarket.Api.Background.EmailNotificationJobs>();
|
||||||
|
|
||||||
// Telegram-бот владельца. Token + username берём из конфига; если token
|
// Telegram-бот владельца. Token + username берём из конфига; если token
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@
|
||||||
<PackageReference Include="prometheus-net.AspNetCore" />
|
<PackageReference Include="prometheus-net.AspNetCore" />
|
||||||
<PackageReference Include="MediatR" />
|
<PackageReference Include="MediatR" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" />
|
||||||
|
<!-- Sprint 20: SSO scaffold (Google + Microsoft external auth). -->
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
83
src/food-market.application/Mapping/MapsterConfig.cs
Normal file
83
src/food-market.application/Mapping/MapsterConfig.cs
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
using Mapster;
|
||||||
|
using foodmarket.Application.Catalog;
|
||||||
|
using foodmarket.Domain.Catalog;
|
||||||
|
|
||||||
|
namespace foodmarket.Application.Mapping;
|
||||||
|
|
||||||
|
/// <summary>Sprint 20 / TD-3: централизованная Mapster-конфигурация
|
||||||
|
/// для проекций domain → DTO. Используется через
|
||||||
|
/// <c>queryable.ProjectToType<TDto>(MapsterConfig.Config)</c>
|
||||||
|
/// — Mapster кодогенерирует SQL-friendly `.Select(...)`-выражение,
|
||||||
|
/// что эквивалентно ручному `Select(p => new TDto(...))` по
|
||||||
|
/// производительности, но компактнее в контроллере.
|
||||||
|
///
|
||||||
|
/// Главное правило записи:
|
||||||
|
/// 1. Все computed-поля (joins, агрегаты) — через `.Map(...)`.
|
||||||
|
/// 2. Все коллекции (Prices, Barcodes) — через `.Map(...)` с
|
||||||
|
/// `.Adapt<TItemDto>()` на каждом элементе.
|
||||||
|
/// 3. PreserveReference = false (default) — для EF-проекций
|
||||||
|
/// циклы не нужны.
|
||||||
|
///
|
||||||
|
/// Регистрация — в `Program.cs`:
|
||||||
|
/// <code>
|
||||||
|
/// var cfg = MapsterConfig.Build();
|
||||||
|
/// services.AddSingleton(cfg);
|
||||||
|
/// services.AddScoped<IMapper, ServiceMapper>();
|
||||||
|
/// </code>
|
||||||
|
/// </summary>
|
||||||
|
public static class MapsterConfig
|
||||||
|
{
|
||||||
|
private static TypeAdapterConfig? _cached;
|
||||||
|
/// <summary>Singleton TypeAdapterConfig. Lazy-initialized чтобы
|
||||||
|
/// тесты тоже могли вызвать без DI.</summary>
|
||||||
|
public static TypeAdapterConfig Config => _cached ??= Build();
|
||||||
|
|
||||||
|
public static TypeAdapterConfig Build()
|
||||||
|
{
|
||||||
|
var cfg = new TypeAdapterConfig();
|
||||||
|
|
||||||
|
cfg.NewConfig<ProductBarcode, ProductBarcodeDto>()
|
||||||
|
.ConstructUsing(src => new ProductBarcodeDto(
|
||||||
|
src.Id, src.Code, src.Type, src.IsPrimary));
|
||||||
|
|
||||||
|
cfg.NewConfig<ProductPrice, ProductPriceDto>()
|
||||||
|
.ConstructUsing(src => new ProductPriceDto(
|
||||||
|
src.Id, src.PriceTypeId, src.PriceType!.Name,
|
||||||
|
src.Amount, src.CurrencyId, src.Currency!.Code));
|
||||||
|
|
||||||
|
cfg.NewConfig<Product, ProductDto>()
|
||||||
|
.ConstructUsing(src => new ProductDto(
|
||||||
|
src.Id, src.Name, src.Article, src.Description,
|
||||||
|
src.UnitOfMeasureId, src.UnitOfMeasure!.Name,
|
||||||
|
src.Vat, src.VatEnabled,
|
||||||
|
src.ProductGroupId, src.ProductGroup!.Name,
|
||||||
|
src.DefaultSupplierId,
|
||||||
|
src.DefaultSupplier != null ? src.DefaultSupplier.Name : null,
|
||||||
|
src.CountryOfOriginId,
|
||||||
|
src.CountryOfOrigin != null ? src.CountryOfOrigin.Name : null,
|
||||||
|
src.IsService, src.Packaging, src.IsMarked,
|
||||||
|
src.MinStock, src.MaxStock,
|
||||||
|
src.ReferencePrice, src.ReferencePriceUpdatedAt,
|
||||||
|
src.PurchaseCurrencyId,
|
||||||
|
src.PurchaseCurrency != null ? src.PurchaseCurrency.Code : null,
|
||||||
|
src.Cost, src.LastSupplyAt,
|
||||||
|
src.ImageUrl,
|
||||||
|
src.IsArchived, src.IsAvailableForSale,
|
||||||
|
src.Prices.Select(pr => new ProductPriceDto(
|
||||||
|
pr.Id, pr.PriceTypeId, pr.PriceType!.Name,
|
||||||
|
pr.Amount, pr.CurrencyId, pr.Currency!.Code)).ToList(),
|
||||||
|
src.Barcodes.Select(b => new ProductBarcodeDto(
|
||||||
|
b.Id, b.Code, b.Type, b.IsPrimary)).ToList()));
|
||||||
|
|
||||||
|
cfg.NewConfig<Counterparty, CounterpartyDto>()
|
||||||
|
.ConstructUsing(src => new CounterpartyDto(
|
||||||
|
src.Id, src.Name, src.LegalName, src.Type,
|
||||||
|
src.Bin, src.Iin, src.TaxNumber,
|
||||||
|
src.CountryId, src.Country != null ? src.Country.Name : null,
|
||||||
|
src.Address, src.Phone, src.Email,
|
||||||
|
src.BankName, src.BankAccount, src.Bik,
|
||||||
|
src.ContactPerson, src.Notes));
|
||||||
|
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,17 @@ interface Props {
|
||||||
}
|
}
|
||||||
const { title, description = 'Программа учёта и касса для розничных магазинов в Казахстане. Бесплатно 90 дней.', ogImage = '/og/home.png' } = Astro.props
|
const { title, description = 'Программа учёта и касса для розничных магазинов в Казахстане. Бесплатно 90 дней.', ogImage = '/og/home.png' } = Astro.props
|
||||||
const canonical = new URL(Astro.url.pathname, Astro.site).toString()
|
const canonical = new URL(Astro.url.pathname, Astro.site).toString()
|
||||||
|
|
||||||
|
// Sprint 20: analytics placeholders. Если в env заданы реальные ID
|
||||||
|
// (PUBLIC_GA_ID, PUBLIC_YM_ID — берутся из import.meta.env) — рендерим
|
||||||
|
// настоящий снипет. Иначе — комментарий-stub чтобы в HTML был визуальный
|
||||||
|
// маркер «здесь должна быть аналитика».
|
||||||
|
//
|
||||||
|
// Подключение: см. docs/analytics.md. В astro.config или .env положите:
|
||||||
|
// PUBLIC_GA_ID=G-XXXXXXXXXX (для Google Analytics 4)
|
||||||
|
// PUBLIC_YM_ID=12345678 (для Yandex.Metrika)
|
||||||
|
const gaId = import.meta.env.PUBLIC_GA_ID ?? 'REPLACE_ME'
|
||||||
|
const ymId = import.meta.env.PUBLIC_YM_ID ?? 'REPLACE_ME'
|
||||||
---
|
---
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ru-KZ">
|
<html lang="ru-KZ">
|
||||||
|
|
@ -45,6 +56,37 @@ const canonical = new URL(Astro.url.pathname, Astro.site).toString()
|
||||||
operatingSystem: 'Web, Windows',
|
operatingSystem: 'Web, Windows',
|
||||||
offers: { '@type': 'Offer', price: '5000', priceCurrency: 'KZT' },
|
offers: { '@type': 'Offer', price: '5000', priceCurrency: 'KZT' },
|
||||||
})} />
|
})} />
|
||||||
|
|
||||||
|
{/* Sprint 20: analytics placeholders. См. docs/analytics.md.
|
||||||
|
Скрипты подключаются только при заданных env-vars; иначе
|
||||||
|
в HTML остаётся data-id="REPLACE_ME" — служит маркером
|
||||||
|
«не настроено» при ручной проверке view-source. */}
|
||||||
|
{gaId !== 'REPLACE_ME' ? (
|
||||||
|
<>
|
||||||
|
<script async src={`https://www.googletagmanager.com/gtag/js?id=${gaId}`}></script>
|
||||||
|
<script set:html={`
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
gtag('js', new Date());
|
||||||
|
gtag('config', '${gaId}', { anonymize_ip: true });
|
||||||
|
`} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<script data-analytics="google" data-id="REPLACE_ME" data-doc="docs/analytics.md" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ymId !== 'REPLACE_ME' ? (
|
||||||
|
<script set:html={`
|
||||||
|
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
||||||
|
m[i].l=1*new Date();
|
||||||
|
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
|
||||||
|
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})
|
||||||
|
(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym");
|
||||||
|
ym(${ymId}, "init", {clickmap:true,trackLinks:true,accurateTrackBounce:true});
|
||||||
|
`} />
|
||||||
|
) : (
|
||||||
|
<script data-analytics="yandex-metrika" data-id="REPLACE_ME" data-doc="docs/analytics.md" />
|
||||||
|
)}
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen flex flex-col">
|
<body class="min-h-screen flex flex-col">
|
||||||
<Header />
|
<Header />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue