food-market/src/food-market.api/Background/DbSchemaDocsJob.cs
nns aa83f82dc5
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
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
feat(s22): data tooling — export/import + schema docs + anon dump (7 пунктов)
1. GDPR org export — domain OrgExport + Phase22a миграция, OrgExportJob
   собирает ZIP с JSON по каждой сущности через IObjectStorage,
   DownloadToken 64-hex + 24h TTL + email-notify.
   POST /api/org/export, GET /api/org/export[/{id}], GET download/{token}.

2. 1C CSV import — POST /api/catalog/products/import/1c-csv:
   Windows-1251/UTF-8 BOM auto-detect, разделитель ;/, русские заголовки
   (Артикул/Наименование/Единица/Цена/Группа/Штрихкод) или английские.
   Нормализация unit-кодов (шт/кг/г/л/мл/упак). Делегирует на ImportCsv
   (транзакция, multi-tenant). docs/imports.md.

3. deploy/anonymize-prod.sh — pg_dump прода → restore во временную БД →
   UPDATE PII (email→user{N}@example.kz, phone→+7700111{N:04}, password→
   тестовый hash, BIN/IIN синтетические, MoySkladToken=NULL, аудиты
   TRUNCATE) → pg_dump → gz файл.

4. DbSchemaDocsJob (weekly вс 05:00 UTC) — information_schema → md с
   таблицами + колонками + FK + mermaid ER-диаграммой (топ-20 таблиц).
   Сохраняет в content-root db-schema-generated.md.

5. POST /api/admin/audit-log/export?format=csv|jsonl — streaming через
   AsAsyncEnumerable. UTF-8 BOM для CSV, JSONL для grep'a. Multi-tenant.

6. GET /api/moysklad/sync-status — агрегат по import_jobs:
   { configured, lastSuccessAt, errorCountLast7Days, pendingCount,
     byKind: { products: KindStatus, counterparties: KindStatus } }.
   Stub если MoySkladToken=null.

7. docs/ARCHITECTURE.md — финальный итог 22 спринтов:
   - Sprint 13-22 changes-сводка
   - «Реализовано полностью» секция
   - «Scaffolding» таблица с указанием что нужно от user'а
   - «Не реализовано» секция (прод, SSO callback, KZ-перевод, POS-тест)
   - Актуальная файловая структура

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 23:00:54 +05:00

155 lines
7.6 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

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

using System.Text;
using foodmarket.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Background;
/// <summary>Sprint 22: weekly-job который генерирует
/// <c>docs/db-schema.md</c> с актуальным списком таблиц + колонок +
/// FK-связей + mermaid ER-диаграммой.
///
/// Источник правды — `information_schema` PostgreSQL, не EF-модель
/// (DbContext не отражает реально применённые миграции, а только
/// модель в коде; задача — задокументировать что РЕАЛЬНО в БД).
///
/// Где-то лежит файл `docs/db-schema.md` в content-root API-сборки.
/// На stage/prod после первого прогона он останется в файловой системе
/// контейнера, но для коммита в git кому-то нужно его прочитать и
/// сохранить (job сам не комитит — это требует git-credentials).
///
/// В простейшем варианте этот job просто пишет файл в `/tmp/db-schema-
/// generated.md` + лог; реальная интеграция с git — отдельный workflow.</summary>
public class DbSchemaDocsJob
{
private readonly AppDbContext _db;
private readonly IConfiguration _cfg;
private readonly ILogger<DbSchemaDocsJob> _log;
private readonly IWebHostEnvironment _env;
public DbSchemaDocsJob(AppDbContext db, IConfiguration cfg, ILogger<DbSchemaDocsJob> log,
IWebHostEnvironment env)
{
_db = db; _cfg = cfg; _log = log; _env = env;
}
public async Task<string> GenerateAsync(CancellationToken ct = default)
{
var sb = new StringBuilder();
sb.AppendLine("# Схема БД food_market");
sb.AppendLine();
sb.AppendLine($"Сгенерировано автоматически: {DateTime.UtcNow:O}.");
sb.AppendLine();
sb.AppendLine("Источник: `information_schema` PostgreSQL public-схемы.");
sb.AppendLine();
// 1. Таблицы + колонки.
sb.AppendLine("## Таблицы");
sb.AppendLine();
var cols = await _db.Database.SqlQuery<ColumnInfo>($@"
SELECT table_name AS ""TableName"",
column_name AS ""ColumnName"",
data_type AS ""DataType"",
COALESCE(character_maximum_length::text, '') AS ""MaxLength"",
is_nullable AS ""IsNullable"",
COALESCE(column_default, '') AS ""Default""
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name NOT LIKE 'AspNet%' -- скроем Identity (общая schema)
AND table_name NOT LIKE 'OpenIddict%' -- скроем OpenIddict
AND table_name NOT LIKE '__EFMigrations%'
ORDER BY table_name, ordinal_position").ToListAsync(ct);
var byTable = cols.GroupBy(c => c.TableName).OrderBy(g => g.Key).ToList();
foreach (var grp in byTable)
{
sb.AppendLine($"### `{grp.Key}`");
sb.AppendLine();
sb.AppendLine("| Колонка | Тип | Nullable | Default |");
sb.AppendLine("|---|---|---|---|");
foreach (var c in grp)
{
var typeStr = string.IsNullOrEmpty(c.MaxLength) ? c.DataType : $"{c.DataType}({c.MaxLength})";
var def = c.Default.Length > 30 ? c.Default[..30] + "…" : c.Default;
sb.AppendLine($"| {c.ColumnName} | {typeStr} | {c.IsNullable} | {def} |");
}
sb.AppendLine();
}
// 2. FK-связи (для понимания JOIN'ов).
sb.AppendLine("## Связи (Foreign Keys)");
sb.AppendLine();
var fks = await _db.Database.SqlQuery<FkInfo>($@"
SELECT
tc.table_name AS ""FromTable"",
kcu.column_name AS ""FromColumn"",
ccu.table_name AS ""ToTable"",
ccu.column_name AS ""ToColumn""
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema = 'public'
AND tc.table_name NOT LIKE 'AspNet%'
AND tc.table_name NOT LIKE 'OpenIddict%'
ORDER BY tc.table_name, kcu.column_name").ToListAsync(ct);
sb.AppendLine("| Из таблицы | Колонка | → | В таблицу | Колонка |");
sb.AppendLine("|---|---|---|---|---|");
foreach (var fk in fks)
sb.AppendLine($"| {fk.FromTable} | {fk.FromColumn} | → | {fk.ToTable} | {fk.ToColumn} |");
sb.AppendLine();
// 3. Mermaid ER-диаграмма (ограничиваем top-30 таблиц по кол-ву связей
// чтобы не получить нечитаемый клубок).
sb.AppendLine("## ER-диаграмма (mermaid)");
sb.AppendLine();
sb.AppendLine("Ограничено топ-20 таблицами по числу FK-связей.");
sb.AppendLine();
sb.AppendLine("```mermaid");
sb.AppendLine("erDiagram");
var fkByTable = fks.GroupBy(f => f.FromTable)
.OrderByDescending(g => g.Count())
.Take(20)
.Select(g => g.Key)
.ToHashSet();
foreach (var fk in fks.Where(f => fkByTable.Contains(f.FromTable) || fkByTable.Contains(f.ToTable)))
{
sb.AppendLine($" {fk.FromTable} }}o--|| {fk.ToTable} : {fk.FromColumn}");
}
sb.AppendLine("```");
sb.AppendLine();
sb.AppendLine("## Что НЕ показано");
sb.AppendLine();
sb.AppendLine("- Таблицы `AspNet*` (ASP.NET Identity) и `OpenIddict*` — стандартные, см. их документацию.");
sb.AppendLine("- Hangfire-таблицы в схеме `hangfire` — внутренние, не часть domain-модели.");
sb.AppendLine("- Индексы — см. отдельный `\\di+` в psql.");
var content = sb.ToString();
// Сохраняем в content-root API. На stage/prod после рестарта файл
// пропадёт (volume не привязан), но это норма для job'a — он
// подхватывается weekly и переписывает. Реальная фиксация в git
// — через отдельный workflow (см. .forgejo/workflows/db-schema-docs.yml).
var path = Path.Combine(_env.ContentRootPath, "db-schema-generated.md");
try
{
await File.WriteAllTextAsync(path, content, ct);
_log.LogInformation("DbSchemaDocs: записан {Path} ({Bytes} bytes)", path, content.Length);
}
catch (Exception ex)
{
_log.LogWarning(ex, "DbSchemaDocs: не удалось записать {Path}", path);
}
return content;
}
private record ColumnInfo(string TableName, string ColumnName, string DataType,
string MaxLength, string IsNullable, string Default);
private record FkInfo(string FromTable, string FromColumn, string ToTable, string ToColumn);
}