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>
|
||||
<!-- ASP.NET Core 8 -->
|
||||
<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.SignalR" Version="1.2.0" />
|
||||
<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,
|
||||
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,
|
||||
// low-stock каждый день в 08:00 UTC (после weekly чтобы не дублить
|
||||
// если оба совпадают). Cron-ы конфигурируются — на тестовом стенде
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
using foodmarket.Domain.Inventory;
|
||||
using foodmarket.Domain.Purchases;
|
||||
using foodmarket.Domain.Sales;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
|
|
@ -61,4 +63,75 @@ public async Task<int> PruneAuditLogAsync(CancellationToken ct = default)
|
|||
deleted, threshold);
|
||||
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.Api.Infrastructure.Authorization;
|
||||
using foodmarket.Api.Controllers.Reports;
|
||||
using foodmarket.Application.Mapping;
|
||||
using Mapster;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
|
@ -49,13 +51,10 @@ public class CounterpartiesController : ControllerBase
|
|||
("name", true) => q.OrderByDescending(c => c.Name),
|
||||
_ => q.OrderBy(c => c.Name),
|
||||
};
|
||||
// Sprint 20 / TD-3: ProjectToType вместо ручного Select.
|
||||
var items = await q
|
||||
.Skip(req.Skip).Take(req.Take)
|
||||
.Select(c => new CounterpartyDto(
|
||||
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))
|
||||
.ProjectToType<CounterpartyDto>(MapsterConfig.Config)
|
||||
.ToListAsync(ct);
|
||||
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}")]
|
||||
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);
|
||||
return c is null ? NotFound() : new CounterpartyDto(
|
||||
c.Id, c.Name, c.LegalName, c.Type,
|
||||
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name,
|
||||
c.Address, c.Phone, c.Email,
|
||||
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes);
|
||||
// Sprint 20 / TD-3: ProjectToType — Mapster выберет только нужные
|
||||
// поля (включая Country.Name), без Include на полный entity.
|
||||
var dto = await _db.Counterparties.AsNoTracking().Where(x => x.Id == id)
|
||||
.ProjectToType<CounterpartyDto>(MapsterConfig.Config)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
return dto is null ? NotFound() : dto;
|
||||
}
|
||||
|
||||
[HttpPost, RequiresPermission("CounterpartiesEdit")]
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
using foodmarket.Infrastructure.Persistence;
|
||||
using foodmarket.Api.Infrastructure.Authorization;
|
||||
using foodmarket.Api.Controllers.Reports;
|
||||
using foodmarket.Application.Mapping;
|
||||
using Mapster;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
|
@ -196,9 +198,12 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
|
|||
("name", true) => q.OrderByDescending(p => p.Name),
|
||||
_ => q.OrderBy(p => p.Name),
|
||||
};
|
||||
// Sprint 20 / TD-3: ProjectToType<ProductDto>() через Mapster.
|
||||
// Эквивалент `.Select(Projection)` по SQL, но проекция централизована
|
||||
// в MapsterConfig — добавление новой DTO больше не правит контроллер.
|
||||
var items = await q
|
||||
.Skip(req.Skip).Take(req.Take)
|
||||
.Select(Projection)
|
||||
.ProjectToType<ProductDto>(MapsterConfig.Config)
|
||||
.ToListAsync(ct);
|
||||
return new PagedResult<ProductDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||
}
|
||||
|
|
@ -254,7 +259,9 @@ public record ExportRow(
|
|||
[HttpGet("{id:guid}")]
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -956,25 +963,12 @@ public record ByBarcodeResult(IReadOnlyList<QuickSearchItem> Items);
|
|||
.Include(p => p.Prices).ThenInclude(pr => pr.Currency);
|
||||
|
||||
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 =>
|
||||
new ProductDto(
|
||||
p.Id, p.Name, p.Article, p.Description,
|
||||
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());
|
||||
// Sprint 20 / TD-3: ручная Projection-Expression удалена. Маппинг
|
||||
// Product → ProductDto живёт в `MapsterConfig.Build()` и применяется
|
||||
// через `.ProjectToType<ProductDto>(MapsterConfig.Config)`.
|
||||
|
||||
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)
|
||||
=> 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.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/
|
||||
// Storekeeper/Cashier при наличии X-Org-Override, чтобы [Authorize(Roles=...)]
|
||||
// не отшивал edit-mode мутации SuperAdmin'а. См. SuperAdminOverrideClaimsTransformer.
|
||||
|
|
@ -155,12 +162,54 @@ static string ApplyDefaultPoolConfig(string? raw)
|
|||
|
||||
// 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.
|
||||
builder.Services.AddAuthentication(options =>
|
||||
var authBuilder = builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
|
||||
options.DefaultAuthenticateScheme = 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 =>
|
||||
{
|
||||
// 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.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>();
|
||||
|
||||
// Telegram-бот владельца. Token + username берём из конфига; если token
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@
|
|||
<PackageReference Include="prometheus-net.AspNetCore" />
|
||||
<PackageReference Include="MediatR" />
|
||||
<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>
|
||||
|
|
|
|||
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 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>
|
||||
<html lang="ru-KZ">
|
||||
|
|
@ -45,6 +56,37 @@ const canonical = new URL(Astro.url.pathname, Astro.site).toString()
|
|||
operatingSystem: 'Web, Windows',
|
||||
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>
|
||||
<body class="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
|
|
|
|||
Loading…
Reference in a new issue