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
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>
155 lines
7.6 KiB
C#
155 lines
7.6 KiB
C#
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);
|
||
}
|