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:
@@ -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, безопасность сервисов, лимиты — отдельные планы следующих этапов.
|
||||
Reference in New Issue
Block a user