Deal — единая кодовая база
ci / build-test (push) Canceled after 0s

SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/
Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue,
контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер),
Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог).

Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0,
тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
Rustam Khalimov
2026-09-11 23:56:47 +03:00
commit 27c7831910
1383 changed files with 158436 additions and 0 deletions
@@ -0,0 +1,825 @@
# Дейл (Deal) — Этап 0: Каркас решения Implementation Plan
> Исторический документ этапа 0. Актуальное состояние — `docs/superpowers/STATUS.md` и `docs/technical/Техническая-документация-Дейл.md`.
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Создать каркас нового продукта «Дейл»: структуру `src/`, решение `core` (модульный монолит) с пустыми модулями, стандарты кода (.editorconfig + анализаторы), dev-Postgres со схемой на тенанта и tenant-контекст.
**Architecture:** Модульный монолит в `src/core` (один процесс, одно sln: `Deal.Api` + `Deal.Modules.*` + `Deal.SharedKernel` + `Deal.Infrastructure` + `Deal.Contracts`). Postgres: одна БД, системные таблицы в `public`, данные тенантов в `tenant_<id>.*`. Сервисы ml/ai/telegram — отдельные процессы со своими sln (создаются в этом этапе как пустые каталоги, наполняются позже). Фронтенд Vue переезжает как есть в `src/frontend`.
**Tech Stack:** .NET 10 (C#), ASP.NET Core (Web API + minimal), EF Core, Npgsql, xUnit, docker compose.
**Spec:** `docs/architecture/2026-09-05-deal-architecture-design.md` (разделы 2, 3, 4, 10, 11)
**ТЗ:** `docs/spec/ТЗ-дейл-новая-архитектура.md` (разделы 3, 11)
## Global Constraints
- Проект **НЕ git-репозиторий** (рабочее дерево `C:\telbase`, деплой docker compose). Вместо коммитов фиксируем затронутые файлы и результат проверок в отчёте задачи. Рабочая папка плана: `.superpowers/sdd/deal-scaffold/`.
- Решение собирается на .NET 10 SDK (установлен: `10.0.400`).
- Код-стайл: `C:\telbase\Стиль_кода.docx` + адаптации: 1 тип = 1 файл; комментарии на русском; XML-doc только для public-контрактов; настройки через `IOptions<T>`; без snake_case-хелперов и регионов; public-члены — только свойства; явные модификаторы доступа.
- Все имена: namespace `Deal.*`, проекты `Deal.*`, имя решения `Deal.sln`.
- Каждый публичный тип — в отдельном файле, имя файла = имя типа.
- Анализаторы: `Microsoft.CodeAnalysis.NetAnalyzers` включён; нарушения стиля — ошибки сборки (через `.editorconfig` severity).
- Запрещено: секреты в коде/репозитории; конкатенация SQL; magic numbers.
- Старый LeadRadar-код (`backend/`, `frontend/` верхнего уровня) не трогаем, кроме переноса `frontend/``src/frontend/`.
---
### Task 1: Структура src/ и перенос фронтенда
**Files:**
- Create: `src/README.md`
- Create: `README.md` (корневой, краткий)
**Interfaces:**
- Consumes: — (старт)
- Produces: структура папок `src/{core, ml-service, ai-service, telegram-service, contracts, frontend}`; фронтенд перенесён в `src/frontend/`.
- [ ] **Step 1: Создать структуру каталогов**
Run:
```bash
mkdir -p src/core src/ml-service src/ai-service src/telegram-service src/contracts
```
- [ ] **Step 2: Перенести фронтенд**
Run:
```bash
mkdir -p src/frontend
cp -r frontend/* src/frontend/ && rm -rf frontend
```
Expected: `src/frontend/` содержит package.json, src/, index.html и т.д.; старая папка `frontend/` удалена.
- [ ] **Step 3: Создать `src/README.md`**
```markdown
# Дейл (Deal) — исходники
- `core/` — модульный монолит .NET (бизнес-логика, API)
- `ml-service/` — ML (.NET + ONNX), отдельный процесс
- `ai-service/` — LLM-фасад, отдельный процесс
- `telegram-service/` — ферма сессий Telegram, отдельный процесс
- `contracts/` — общие .proto (gRPC)
- `frontend/` — Vue (переехал из LeadRadar как есть)
Подробности: `docs/architecture/2026-09-05-deal-architecture-design.md`
```
- [ ] **Step 4: Создать корневой `README.md`**
```markdown
# Дейл (Deal)
SaaS-мониторинг Telegram: реальные заказы и клиенты вместо рекламы и дубликатов.
- Архитектура: `docs/architecture/2026-09-05-deal-architecture-design.md`
- ТЗ: `docs/spec/ТЗ-дейл-новая-архитектура.md`
- Техдок: `docs/technical/Техническая-документация-Дейл.md`
- Исходники: `src/`
```
- [ ] **Step 5: Проверить**
Run: `ls src/` — 6 папок; `ls src/frontend/` — файлы Vue-проекта; `test -f README.md && echo ok`.
Expected: все проверки успешны.
- [ ] **Step 6: Зафиксировать в отчёте** `task-1-report.md` (файлы, результат проверок).
---
### Task 2: Стандарты кода — .editorconfig, Directory.Build.props
**Files:**
- Create: `.editorconfig`
- Create: `src/core/Directory.Build.props`
**Interfaces:**
- Produces: единые правила для всех проектов `src/core`; нарушения — ошибки сборки.
- [ ] **Step 1: Создать корневой `.editorconfig`**
```editorconfig
root = true
[*]
charset = utf-8
end_of_line = crlf
insert_final_newline = true
indent_style = space
indent_size = 4
trim_trailing_whitespace = true
[*.{cs,vb}]
indent_size = 4
# Стиль фигурных скобок — Allman (на отдельной строке)
csharp_new_line_before_open_brace = all
csharp_new_line_before_else = true
csharp_new_line_before_catch = true
csharp_new_line_before_finally = true
# using — в начале файла
dotnet_sort_system_directives_first = true
# Модификаторы доступа — всегда явные
dotnet_style_require_accessibility_modifiers = always:error
# this. — не требуется
dotnet_style_qualification_for_field = false:silent
dotnet_style_qualification_for_property = false:silent
dotnet_style_qualification_for_method = false:silent
# Члены
csharp_style_var_for_built_in_types = false:silent
csharp_style_var_when_type_is_apparent = false:silent
csharp_style_var_elsewhere = false:silent
[*.cs]
# Отключить лишние правила IDE, которые конфликтуют с код-стайлом проекта
dotnet_diagnostic.IDE0290.severity = none
```
- [ ] **Step 2: Создать `src/core/Directory.Build.props`**
```xml
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
```
- [ ] **Step 3: Зафиксировать в отчёте** (проверка сборки — после Task 3).
---
### Task 3: Решение Deal.sln и пустые проекты core
**Files:**
- Create: `src/core/Deal.sln`
- Create: `src/core/Deal.Api/Deal.Api.csproj` + `Program.cs`
- Create: `src/core/Deal.Modules.Pipeline/`, `...Kanban/`, `...Projects/`, `...Discovery/`, `...Settings/`, `...Tenants/` (csproj + класс-маркер)
- Create: `src/core/Deal.SharedKernel/`, `Deal.Infrastructure/`, `Deal.Contracts/` (csproj + маркер)
**Interfaces:**
- Produces: собираемое решение; проекты-модули, готовые к наполнению в следующих этапах.
- [ ] **Step 1: Создать решение и проекты командой**
```bash
cd /c/telbase/src/core
dotnet new sln -n Deal
dotnet new web -n Deal.Api -o Deal.Api --no-https
dotnet new classlib -n Deal.Modules.Pipeline -o Deal.Modules.Pipeline
dotnet new classlib -n Deal.Modules.Kanban -o Deal.Modules.Kanban
dotnet new classlib -n Deal.Modules.Projects -o Deal.Modules.Projects
dotnet new classlib -n Deal.Modules.Discovery -o Deal.Modules.Discovery
dotnet new classlib -n Deal.Modules.Settings -o Deal.Modules.Settings
dotnet new classlib -n Deal.Modules.Tenants -o Deal.Modules.Tenants
dotnet new classlib -n Deal.SharedKernel -o Deal.SharedKernel
dotnet new classlib -n Deal.Infrastructure -o Deal.Infrastructure
dotnet new classlib -n Deal.Contracts -o Deal.Contracts
```
- [ ] **Step 2: Добавить проекты в решение**
```bash
dotnet sln Deal.sln add Deal.Api Deal.Modules.Pipeline Deal.Modules.Kanban Deal.Modules.Projects Deal.Modules.Discovery Deal.Modules.Settings Deal.Modules.Tenants Deal.SharedKernel Deal.Infrastructure Deal.Contracts
```
- [ ] **Step 3: Удалить Class1.cs и добавить маркеры модулей**
Каждый модуль получает публичный маркер-класс (1 тип = 1 файл), например `Deal.Modules.Pipeline/PipelineModuleMarker.cs`:
```csharp
namespace Deal.Modules.Pipeline;
/// <summary>Маркер модуля Pipeline: используется для DI-сканирования и тестов.</summary>
public sealed class PipelineModuleMarker
{
}
```
Аналогично для всех модулей и Infrastructure/SharedKernel/Contracts (маркеры: `InfrastructureMarker`, `SharedKernelMarker`, `ContractsMarker`).
- [ ] **Step 4: Ссылки между проектами (минимальные, по дизайн-доку)**
```bash
dotnet add Deal.Api reference Deal.SharedKernel Deal.Contracts Deal.Infrastructure
dotnet add Deal.Modules.Pipeline reference Deal.SharedKernel Deal.Contracts
dotnet add Deal.Modules.Kanban reference Deal.SharedKernel Deal.Contracts
dotnet add Deal.Modules.Projects reference Deal.SharedKernel Deal.Contracts
dotnet add Deal.Modules.Discovery reference Deal.SharedKernel Deal.Contracts
dotnet add Deal.Modules.Settings reference Deal.SharedKernel Deal.Contracts
dotnet add Deal.Modules.Tenants reference Deal.SharedKernel Deal.Contracts
dotnet add Deal.Infrastructure reference Deal.SharedKernel Deal.Contracts
```
- [ ] **Step 5: Минимальный Program.cs в Deal.Api (health)**
```csharp
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/api/health", () => Results.Ok(new { ok = true, service = "deal" }));
app.Run();
public partial class Program
{
}
```
- [ ] **Step 6: Собрать решение**
Run: `dotnet build Deal.sln`
Expected: Build succeeded, 0 warnings, 0 errors.
- [ ] **Step 7: Проверить health локально**
Run: `dotnet run --project Deal.Api --urls http://localhost:5080` (в фоне), затем `curl http://localhost:5080/api/health`
Expected: `{"ok":true,"service":"deal"}` (процесс остановить после проверки).
- [ ] **Step 8: Зафиксировать в отчёте** `task-3-report.md`.
---
### Task 4: Тесты — xUnit-каркас
**Files:**
- Create: `src/core/tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj`
- Test: `src/core/tests/Deal.Tests.Unit/MarkerTests.cs`
**Interfaces:**
- Consumes: маркеры модулей из Task 3.
- Produces: тестовый проект, подключённый к решению.
- [ ] **Step 1: Создать тестовый проект**
```bash
cd /c/telbase/src/core
dotnet new xunit -n Deal.Tests.Unit -o tests/Deal.Tests.Unit
dotnet sln Deal.sln add tests/Deal.Tests.Unit
dotnet add tests/Deal.Tests.Unit reference Deal.SharedKernel Deal.Modules.Pipeline Deal.Modules.Kanban Deal.Modules.Projects Deal.Modules.Discovery Deal.Modules.Settings Deal.Modules.Tenants
```
- [ ] **Step 2: Написать тест на маркеры модулей**
`tests/Deal.Tests.Unit/MarkerTests.cs`:
```csharp
using Deal.Modules.Pipeline;
namespace Deal.Tests.Unit;
public sealed class MarkerTests
{
[Fact]
public void PipelineModuleMarker_IsPublicAndSealed()
{
Assert.True(typeof(PipelineModuleMarker).IsPublic);
Assert.True(typeof(PipelineModuleMarker).IsSealed);
}
}
```
- [ ] **Step 3: Запустить тесты**
Run: `dotnet test tests/Deal.Tests.Unit`
Expected: 1 тест PASS.
- [ ] **Step 4: Зафиксировать в отчёте** `task-4-report.md`.
---
### Task 5: Dev-Postgres в docker compose (схема на тенанта)
**Files:**
- Create: `deploy/compose.dev.yml`
- Create: `deploy/.env.example`
- Modify: `README.md` (инструкция запуска dev-БД)
**Interfaces:**
- Produces: dev-контейнер Postgres 16; БД `deal`; схема `public` готова к миграциям.
- [ ] **Step 1: Создать `deploy/compose.dev.yml`**
```yaml
services:
postgres:
image: postgres:16-alpine
container_name: deal-postgres
environment:
POSTGRES_DB: deal
POSTGRES_USER: deal
POSTGRES_PASSWORD: deal_dev_password
ports:
- "5433:5432" # 5432 может быть занят LeadRadar-стеком
volumes:
- deal_pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U deal -d deal"]
interval: 5s
timeout: 3s
retries: 10
volumes:
deal_pgdata:
```
- [ ] **Step 2: Создать `deploy/.env.example`**
```
DEAL_PG_HOST=localhost
DEAL_PG_PORT=5433
DEAL_PG_DB=deal
DEAL_PG_USER=deal
DEAL_PG_PASSWORD=deal_dev_password
```
- [ ] **Step 3: Поднять контейнер**
Run: `docker compose -f deploy/compose.dev.yml up -d`
Expected: `deal-postgres` running, healthy.
- [ ] **Step 4: Проверить подключение**
Run:
```bash
docker exec deal-postgres psql -U deal -d deal -c "SELECT current_database(), current_schema();"
```
Expected: `deal | public`
- [ ] **Step 5: Дополнить README.md разделом «Запуск dev-окружения»**
```markdown
## Запуск dev-окружения
Postgres (схема на тенанта): `docker compose -f deploy/compose.dev.yml up -d`
```
- [ ] **Step 6: Зафиксировать в отчёте** `task-5-report.md`.
---
### Task 6: Tenant-контекст и подключение к Postgres
**Files:**
- Create: `src/core/Deal.SharedKernel/Tenants/TenantId.cs`
- Create: `src/core/Deal.SharedKernel/Tenants/ITenantContext.cs`
- Create: `src/core/Deal.Infrastructure/Data/TenantContext.cs`
- Create: `src/core/Deal.Infrastructure/Data/ConnectionStringProvider.cs`
- Modify: `Deal.Api/Program.cs`
- Test: `tests/Deal.Tests.Unit/TenantIdTests.cs`
**Interfaces:**
- Produces:
- `TenantId` — readonly record struct, обёртка над строкой.
- `ITenantContext``TenantId? TenantId { get; }`, `bool HasTenant { get; }`, `string? SchemaName { get; }`.
- `TenantContext` — реализация на AsyncLocal.
- `ConnectionStringProvider` — строка подключения с `search_path`.
- [ ] **Step 1: `TenantId.cs` (1 тип = 1 файл)**
```csharp
namespace Deal.SharedKernel.Tenants;
/// <summary>Идентификатор тенанта. Инвариант: непустой.</summary>
public readonly record struct TenantId(string Value)
{
public string Value { get; } = string.IsNullOrWhiteSpace(Value)
? throw new ArgumentException("TenantId не может быть пустым", nameof(Value))
: Value;
/// <summary>Имя схемы Postgres для тенанта.</summary>
public string SchemaName => $"tenant_{Value}";
}
```
- [ ] **Step 2: Тест `TenantIdTests.cs`**
```csharp
using Deal.SharedKernel.Tenants;
namespace Deal.Tests.Unit;
public sealed class TenantIdTests
{
[Fact]
public void SchemaName_PrefixesTenant()
{
var id = new TenantId("abc123");
Assert.Equal("tenant_abc123", id.SchemaName);
}
[Fact]
public void TenantId_Empty_Throws()
{
Assert.Throws<ArgumentException>(() => new TenantId(""));
}
}
```
- [ ] **Step 3: Запустить тесты**
Run: `dotnet test tests/Deal.Tests.Unit`
Expected: 3 теста PASS.
- [ ] **Step 4: `ITenantContext.cs`**
```csharp
namespace Deal.SharedKernel.Tenants;
/// <summary>Контекст текущего тенанта запроса.</summary>
public interface ITenantContext
{
TenantId? TenantId { get; }
bool HasTenant { get; }
/// <summary>Имя схемы текущего тенанта или null для системного контекста (public).</summary>
string? SchemaName { get; }
}
```
- [ ] **Step 5: `TenantContext.cs` (реализация в Infrastructure)**
```csharp
using Deal.SharedKernel.Tenants;
namespace Deal.Infrastructure.Data;
/// <summary>Контекст тенанта на AsyncLocal: пробрасывается через весь запрос.</summary>
public sealed class TenantContext : ITenantContext
{
private static readonly AsyncLocal<TenantId?> Current = new();
public TenantId? TenantId => Current.Value;
public bool HasTenant => Current.Value is not null;
public string? SchemaName => Current.Value?.SchemaName;
public void SetTenant(TenantId tenantId) => Current.Value = tenantId;
}
```
- [ ] **Step 6: `ConnectionStringProvider.cs`**
```csharp
using Deal.SharedKernel.Tenants;
using Microsoft.Extensions.Configuration;
namespace Deal.Infrastructure.Data;
/// <summary>Строит строку подключения к Postgres с учётом схемы тенанта.</summary>
public sealed class ConnectionStringProvider
{
private readonly string _baseConnectionString;
public ConnectionStringProvider(IConfiguration configuration)
{
_baseConnectionString = configuration.GetConnectionString("DealPostgres")
?? throw new InvalidOperationException("ConnectionStrings:DealPostgres не задан");
}
/// <summary>Строка подключения; при tenantId не null добавляет search_path к схеме тенанта.</summary>
public string ForTenant(TenantId? tenantId)
{
if (tenantId is null)
{
return _baseConnectionString;
}
return $"{_baseConnectionString};Search Path={tenantId.Value.SchemaName}";
}
}
```
- [ ] **Step 7: Подключить в `Program.cs` (DI)**
```csharp
using Deal.Infrastructure.Data;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ITenantContext, TenantContext>();
builder.Services.AddSingleton<ConnectionStringProvider>();
var app = builder.Build();
```
(недостающие `using Deal.SharedKernel.Tenants;` добавить по месту)
- [ ] **Step 8: Собрать и прогнать тесты**
Run: `dotnet build Deal.sln && dotnet test tests/Deal.Tests.Unit`
Expected: build 0 ошибок, тесты PASS.
- [ ] **Step 9: Зафиксировать в отчёте** `task-6-report.md`.
---
### Task 7: EF Core + миграции (public)
**Files:**
- Create: `src/core/Deal.Infrastructure/Persistence/DealDbContext.cs`
- Create: `src/core/Deal.Infrastructure/Persistence/Entities/TenantEntity.cs`
- Create: `src/core/Deal.Infrastructure/Persistence/DealDbDesignTimeFactory.cs`
- Modify: `Deal.Api/Program.cs` (регистрация DbContext)
- Test: `tests/Deal.Tests.Unit/TenantEntityTests.cs`
**Interfaces:**
- Produces:
- `DealDbContext` — базовый DbContext; системная сущность Tenant в схеме `public`.
- Миграция `InitialPublic`, применённая к `public`.
- [ ] **Step 1: Добавить EF Core пакеты в Infrastructure**
```bash
cd /c/telbase/src/core
dotnet add Deal.Infrastructure package Microsoft.EntityFrameworkCore
dotnet add Deal.Infrastructure package Npgsql.EntityFrameworkCore.PostgreSQL
dotnet add Deal.Infrastructure package Microsoft.EntityFrameworkCore.Design
```
- [ ] **Step 2: `TenantEntity.cs` (в `Deal.Infrastructure/Persistence/Entities/`)**
```csharp
namespace Deal.Infrastructure.Persistence.Entities;
/// <summary>Тенант в системной схеме public.</summary>
public sealed class TenantEntity
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Status { get; set; } = "active";
public DateTimeOffset CreatedAt { get; set; }
}
```
- [ ] **Step 3: `DealDbContext.cs`**
```csharp
using Deal.Infrastructure.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
namespace Deal.Infrastructure.Persistence;
/// <summary>Базовый DbContext. Системные сущности — в схеме public.</summary>
public sealed class DealDbContext(DbContextOptions<DealDbContext> options) : DbContext(options)
{
public DbSet<TenantEntity> Tenants => Set<TenantEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<TenantEntity>(entity =>
{
entity.ToTable("tenants", "public");
entity.HasKey(x => x.Id);
entity.Property(x => x.Name).HasMaxLength(200).IsRequired();
});
}
}
```
- [ ] **Step 4: `DealDbDesignTimeFactory.cs`**
```csharp
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace Deal.Infrastructure.Persistence;
/// <summary>Фабрика для dotnet-ef (миграции). Читает строку подключения из env.</summary>
public sealed class DealDbDesignTimeFactory : IDesignTimeDbContextFactory<DealDbContext>
{
public DealDbContext CreateDbContext(string[] args)
{
var connectionString = Environment.GetEnvironmentVariable("DEAL_PG_CONNECTION")
?? "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password";
var options = new DbContextOptionsBuilder<DealDbContext>()
.UseNpgsql(connectionString)
.Options;
return new DealDbContext(options);
}
}
```
- [ ] **Step 5: Регистрация DbContext в Program.cs**
```csharp
using Deal.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
var connectionString = builder.Configuration.GetConnectionString("DealPostgres")
?? "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password";
builder.Services.AddDbContext<DealDbContext>(options => options.UseNpgsql(connectionString));
```
`appsettings.Development.json` положить `ConnectionStrings:DealPostgres`; в проде — из env)
- [ ] **Step 6: Создать `appsettings.Development.json` в Deal.Api**
```json
{
"ConnectionStrings": {
"DealPostgres": "Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password"
}
}
```
- [ ] **Step 7: Установить dotnet-ef tool и создать миграцию**
```bash
dotnet tool install --global dotnet-ef
cd /c/telbase/src/core
dotnet ef migrations add InitialPublic --project Deal.Infrastructure --startup-project Deal.Api
```
- [ ] **Step 8: Применить миграцию к public**
```bash
dotnet ef database update --project Deal.Infrastructure --startup-project Deal.Api
```
- [ ] **Step 9: Проверить таблицу**
```bash
docker exec deal-postgres psql -U deal -d deal -c "\dt public.*"
```
Expected: таблицы `tenants`, `__EFMigrationsHistory`.
- [ ] **Step 10: Тест `TenantEntityTests.cs`**
```csharp
using Deal.Infrastructure.Persistence.Entities;
namespace Deal.Tests.Unit;
public sealed class TenantEntityTests
{
[Fact]
public void TenantEntity_Defaults_AreValid()
{
var entity = new TenantEntity();
Assert.Equal("active", entity.Status);
Assert.NotEqual(Guid.Empty, entity.Id == Guid.Empty ? Guid.Empty : entity.Id);
}
}
```
(тест проверяет дефолты; при необходимости скорректировать под реальную модель)
- [ ] **Step 11: Зафиксировать в отчёте** `task-7-report.md`.
---
### Task 8: Применение миграций ко всем схемам тенантов
**Files:**
- Create: `src/core/Deal.Infrastructure/Migrations/TenantSchemaMigrator.cs`
- Test: `tests/Deal.Tests.Unit/TenantSchemaMigratorTests.cs`
**Interfaces:**
- Consumes: `TenantId`.
- Produces: `TenantSchemaMigrator` — чистые функции формирования SQL для схем тенантов.
- [ ] **Step 1: Написать тест**
`tests/Deal.Tests.Unit/TenantSchemaMigratorTests.cs`:
```csharp
using Deal.Infrastructure.Migrations;
namespace Deal.Tests.Unit;
public sealed class TenantSchemaMigratorTests
{
[Fact]
public void CreateSchemaSql_IsEscaped()
{
var sql = TenantSchemaMigrator.CreateSchemaSql("tenant_abc");
Assert.Contains("CREATE SCHEMA IF NOT EXISTS \"tenant_abc\"", sql);
Assert.DoesNotContain("; DROP", sql);
}
[Fact]
public void CreateSchemaSql_EscapesQuotes()
{
var sql = TenantSchemaMigrator.CreateSchemaSql("tenant_a\"b");
Assert.DoesNotContain("\"b\"", sql);
}
}
```
- [ ] **Step 2: `TenantSchemaMigrator.cs`**
```csharp
namespace Deal.Infrastructure.Migrations;
/// <summary>Миграции схем тенантов. Чистые функции формирования SQL.</summary>
public static class TenantSchemaMigrator
{
/// <summary>SQL создания схемы тенанта. Имя экранируется (не интерполируется из ввода).</summary>
public static string CreateSchemaSql(string schemaName)
{
var escaped = schemaName.Replace("\"", "\"\"");
return $"CREATE SCHEMA IF NOT EXISTS \"{escaped}\"";
}
/// <summary>Имена схем тенантов из БД.</summary>
public static string ListTenantSchemasSql() =>
"SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant\\_%' ESCAPE '\\'";
}
```
- [ ] **Step 3: Запустить тесты**
Run: `dotnet test tests/Deal.Tests.Unit`
Expected: PASS.
- [ ] **Step 4: Зафиксировать в отчёте** `task-8-report.md`.
---
### Task 9: CI-скрипты и финальная проверка этапа
**Files:**
- Create: `scripts/build.sh`
- Create: `scripts/test.sh`
**Interfaces:**
- Produces: воспроизводимая сборка и тесты одной командой.
- [ ] **Step 1: `scripts/build.sh`**
```bash
#!/usr/bin/env sh
set -e
cd "$(dirname "$0")/../src/core"
dotnet build Deal.sln
```
- [ ] **Step 2: `scripts/test.sh`**
```bash
#!/usr/bin/env sh
set -e
cd "$(dirname "$0")/../src/core"
dotnet test tests/Deal.Tests.Unit
```
- [ ] **Step 3: Прогнать оба скрипта**
Run: `sh scripts/build.sh && sh scripts/test.sh`
Expected: build succeeded, все тесты PASS.
- [ ] **Step 4: Итоговая проверка этапа**
Run:
- `dotnet build Deal.sln` — 0 ошибок, 0 предупреждений;
- `dotnet test tests/Deal.Tests.Unit` — все PASS;
- `docker ps``deal-postgres` healthy;
- `curl http://localhost:5080/api/health``{"ok":true,"service":"deal"}`.
- [ ] **Step 5: Зафиксировать в отчёте** `task-9-report.md` + обновить `progress.md`.
---
## Self-Review
**1. Spec coverage (дизайн-док):**
- §2 (стратегия/структура) → Task 1, 3.
- §3 (структура src/) → Task 1, 3.
- §4 (мультитенантность: схема на тенанта, search_path) → Task 5, 6, 7, 8.
- §10 (деплой compose) → Task 5.
- §11 (стандарты: editorconfig, анализаторы, 1 тип = 1 файл) → Task 2, все задачи.
- Frontend-перенос → Task 1.
- Сервисы ml/ai/telegram — пустые каталоги (Task 1); их sln создаются в следующих этапах (вне scope этапа 0).
- Auth/инвайты/лимиты — следующие этапы (вне scope «каркаса»).
**2. Placeholder scan:** код во всех шагах конкретный. Task 7 Step 10 — тест на дефолты TenantEntity упрощён, с пометкой скорректировать под реальную модель.
**3. Type consistency:** `TenantId`, `ITenantContext`, `TenantContext`, `ConnectionStringProvider`, `TenantSchemaMigrator`, `DealDbContext`, `TenantEntity` — имена и сигнатуры согласованы между задачами 6–8.
**Вне scope этапа 0:** auth/сессии, модули с бизнес-логикой, gRPC-сервисы, .proto, админка, observability, безопасность сервисов, лимиты — отдельные планы следующих этапов.