feat(s30): QuickLoginDemoSeeder — организация + Admin/Cashier/Keeper (шаг 4/9)
Hosted-сервис, который на старте, когда EffectivelyEnabled=true, создаёт:
- организацию «Демо-магазин» (отдельная от «Demo Market» DevDataSeeder'а,
чтобы не ломать e2e, завязанные на DemoMarket);
- tenant references через существующий SeedTenantReferencesAsync (единицы,
магазин, PriceType, системные роли);
- каталог демо-товаров через DemoTenantSeeder (50 товаров + 30 продаж +
приёмки/контрагенты) — идемпотентно, маркер DEMO-*;
- три учётки с ролями Admin/Cashier/Storekeeper:
demo-admin@food-market.local
demo-cashier@food-market.local
demo-keeper@food-market.local
(пароль Admin12345!, но мастер-пароль 123456 тоже подойдёт — через
QuickLoginOptions в AuthorizationController).
На проде (EffectivelyEnabled=false) сервис тихо выходит в StartAsync —
ничего не трогает, ничего не логирует.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
ed5089bac3
commit
35f633e206
|
|
@ -24,7 +24,7 @@
|
||||||
quality-*, i18n*-*, auth2*-*, doc4*-*, rep5*-*, rt8*-*, twofa*-*, wiz9*-*,
|
quality-*, i18n*-*, auth2*-*, doc4*-*, rep5*-*, rt8*-*, twofa*-*, wiz9*-*,
|
||||||
a11y-*, mob1*-*, isoB*-*, axdet-*, smoke-*, ratetest-*, perf-*, probe-*,
|
a11y-*, mob1*-*, isoB*-*, axdet-*, smoke-*, ratetest-*, perf-*, probe-*,
|
||||||
load-*, s19-test-* и всё с суффиксом из ≥10 цифр) — скрыты. Лимит 50 + `?q=`.
|
load-*, s19-test-* и всё с суффиксом из ≥10 цифр) — скрыты. Лимит 50 + `?q=`.
|
||||||
- [ ] 4. **Демо-аккаунты** — идемпотентный сид «Демо-магазин» (через
|
- [x] 4. **Демо-аккаунты** — идемпотентный сид «Демо-магазин» (через
|
||||||
существующий `DemoTenantSeeder`) + сотрудники с ролями Admin / Cashier /
|
существующий `DemoTenantSeeder`) + сотрудники с ролями Admin / Cashier /
|
||||||
Storekeeper (`demo-admin@food-market.local`, `demo-cashier@...`,
|
Storekeeper (`demo-admin@food-market.local`, `demo-cashier@...`,
|
||||||
`demo-keeper@...`). Запускается только при Enabled=true.
|
`demo-keeper@...`). Запускается только при Enabled=true.
|
||||||
|
|
|
||||||
|
|
@ -451,6 +451,9 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
||||||
builder.Services.AddHostedService<DevDataSeeder>();
|
builder.Services.AddHostedService<DevDataSeeder>();
|
||||||
// Демо-сидер для stage'a (триггерится через POST /api/admin/seed-demo).
|
// Демо-сидер для stage'a (триггерится через POST /api/admin/seed-demo).
|
||||||
builder.Services.AddScoped<foodmarket.Api.Seed.DemoTenantSeeder>();
|
builder.Services.AddScoped<foodmarket.Api.Seed.DemoTenantSeeder>();
|
||||||
|
// Sprint 30: демо-trio «Демо-магазина» для quick-login — только когда флаг
|
||||||
|
// эффективно включён. Внутри сам чекает EffectivelyEnabled().
|
||||||
|
builder.Services.AddHostedService<foodmarket.Api.Seed.QuickLoginDemoSeeder>();
|
||||||
// Расширенный сидер на год операций (POST /api/admin/seed-demo?years=1).
|
// Расширенный сидер на год операций (POST /api/admin/seed-demo?years=1).
|
||||||
builder.Services.AddScoped<foodmarket.Api.Seed.YearDemoSeeder>();
|
builder.Services.AddScoped<foodmarket.Api.Seed.YearDemoSeeder>();
|
||||||
builder.Services.AddHostedService<foodmarket.Api.Background.ReferencePriceRefreshJob>();
|
builder.Services.AddHostedService<foodmarket.Api.Background.ReferencePriceRefreshJob>();
|
||||||
|
|
|
||||||
179
src/food-market.api/Seed/QuickLoginDemoSeeder.cs
Normal file
179
src/food-market.api/Seed/QuickLoginDemoSeeder.cs
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
using foodmarket.Api.Infrastructure;
|
||||||
|
using foodmarket.Domain.Organizations;
|
||||||
|
using foodmarket.Infrastructure.Identity;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace foodmarket.Api.Seed;
|
||||||
|
|
||||||
|
/// <summary>Поднимает «Демо-магазин» и трёх демо-сотрудников (Admin / Cashier /
|
||||||
|
/// Storekeeper) ПРИ СТАРТЕ, только когда quick-login эффективно включён
|
||||||
|
/// (<see cref="QuickLoginOptions.EffectivelyEnabled"/>). Это тот фикстурный
|
||||||
|
/// набор аккаунтов, который попадает первым в <c>/api/dev/quick-login/accounts</c>
|
||||||
|
/// под IsDemo=true.
|
||||||
|
///
|
||||||
|
/// Идемпотентно: каждая сущность создаётся через проверку по имени/email,
|
||||||
|
/// плюс полный demo-каталог поднимается через существующий
|
||||||
|
/// <see cref="DemoTenantSeeder"/> (маркер DEMO-* защищает от повторных вставок).
|
||||||
|
///
|
||||||
|
/// Если флаг выключен — Hosted-сервис не делает ничего (включая запись
|
||||||
|
/// в лог), чтобы на проде просто не запускаться.</summary>
|
||||||
|
public sealed class QuickLoginDemoSeeder : IHostedService
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly IConfiguration _cfg;
|
||||||
|
private readonly QuickLoginOptions _opts;
|
||||||
|
private readonly ILogger<QuickLoginDemoSeeder> _log;
|
||||||
|
|
||||||
|
public QuickLoginDemoSeeder(
|
||||||
|
IServiceProvider services,
|
||||||
|
IConfiguration cfg,
|
||||||
|
IOptions<QuickLoginOptions> opts,
|
||||||
|
ILogger<QuickLoginDemoSeeder> log)
|
||||||
|
{
|
||||||
|
_services = services;
|
||||||
|
_cfg = cfg;
|
||||||
|
_opts = opts.Value;
|
||||||
|
_log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string OrgName = "Демо-магазин";
|
||||||
|
private const string DemoAdminEmail = "demo-admin@food-market.local";
|
||||||
|
private const string DemoCashierEmail = "demo-cashier@food-market.local";
|
||||||
|
private const string DemoKeeperEmail = "demo-keeper@food-market.local";
|
||||||
|
|
||||||
|
public async Task StartAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var issuer = _cfg["OpenIddict:Issuer"];
|
||||||
|
if (!_opts.EffectivelyEnabled(issuer, out _))
|
||||||
|
{
|
||||||
|
// На проде вообще не запускаемся. В dev без Enabled=true — тоже
|
||||||
|
// не создаём демо-набор, чтобы не отравлять дефолтный localhost.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var userMgr = scope.ServiceProvider.GetRequiredService<UserManager<User>>();
|
||||||
|
|
||||||
|
// 1. «Демо-магазин» (orgEntity). Отдельная org, в отличие от «Demo Market»
|
||||||
|
// из DevDataSeeder — чтобы не трогать e2e, которые завязаны на Demo Market.
|
||||||
|
var demoOrg = await db.Organizations.IgnoreQueryFilters()
|
||||||
|
.FirstOrDefaultAsync(o => o.Name == OrgName, ct);
|
||||||
|
var kzt = await db.Currencies.FirstOrDefaultAsync(c => c.Code == "KZT", ct);
|
||||||
|
if (demoOrg is null)
|
||||||
|
{
|
||||||
|
demoOrg = new Organization
|
||||||
|
{
|
||||||
|
Name = OrgName,
|
||||||
|
CountryCode = "KZ",
|
||||||
|
Bin = "000000000001",
|
||||||
|
Address = "Алматы, ул. Демо-стенд 1",
|
||||||
|
Phone = "+7 (727) 000-00-01",
|
||||||
|
Email = "demo@food-market.local",
|
||||||
|
DefaultCurrencyId = kzt?.Id,
|
||||||
|
};
|
||||||
|
db.Organizations.Add(demoOrg);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
_log.LogInformation("QuickLoginDemoSeeder: создана организация {OrgName}", OrgName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Tenant references (единицы, магазин, PriceType, роли).
|
||||||
|
// SeedTenantReferencesAsync идемпотентен сам по себе.
|
||||||
|
await DevDataSeeder.SeedTenantReferencesAsync(db, demoOrg.Id, ct);
|
||||||
|
|
||||||
|
// 3. Каталог демо-товаров (50 товаров, 10 контрагентов, ...).
|
||||||
|
// Отдельный DemoTenantSeeder — scoped-сервис, уже зарегистрирован в DI.
|
||||||
|
var catalogSeeder = scope.ServiceProvider.GetRequiredService<DemoTenantSeeder>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var summary = await catalogSeeder.SeedAsync(demoOrg.Id, ct);
|
||||||
|
if (!summary.AlreadySeeded)
|
||||||
|
{
|
||||||
|
_log.LogInformation("QuickLoginDemoSeeder: каталог демо наполнен — товаров {Products}, продаж {Sales}",
|
||||||
|
summary.Products, summary.Sales);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Не фатально: демо-данные для стенда — ценные, но не критичные.
|
||||||
|
// Если упал seed — всё равно продолжаем создавать юзеров.
|
||||||
|
_log.LogWarning(ex, "QuickLoginDemoSeeder: демо-каталог не наполнился");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Трио сотрудников. Пароль у них — обычный Admin12345! для совместимости,
|
||||||
|
// но мастер-пароль 123456 всё равно пустит (см. AuthorizationController).
|
||||||
|
var adminRole = await db.EmployeeRoles.IgnoreQueryFilters()
|
||||||
|
.FirstAsync(r => r.OrganizationId == demoOrg.Id && r.IsSystem && r.Name == "Администратор", ct);
|
||||||
|
var cashierRole = await db.EmployeeRoles.IgnoreQueryFilters()
|
||||||
|
.FirstAsync(r => r.OrganizationId == demoOrg.Id && r.IsSystem && r.Name == "Кассир", ct);
|
||||||
|
var keeperRole = await db.EmployeeRoles.IgnoreQueryFilters()
|
||||||
|
.FirstAsync(r => r.OrganizationId == demoOrg.Id && r.IsSystem && r.Name == "Кладовщик", ct);
|
||||||
|
|
||||||
|
await EnsureUserAsync(db, userMgr, demoOrg.Id, DemoAdminEmail, "Демо Админ Магазина",
|
||||||
|
SystemRoles.Admin, adminRole.Id, "Администратор", ct);
|
||||||
|
await EnsureUserAsync(db, userMgr, demoOrg.Id, DemoCashierEmail, "Демо Кассир",
|
||||||
|
SystemRoles.Cashier, cashierRole.Id, "Кассир", ct);
|
||||||
|
await EnsureUserAsync(db, userMgr, demoOrg.Id, DemoKeeperEmail, "Демо Кладовщик",
|
||||||
|
SystemRoles.Storekeeper, keeperRole.Id, "Кладовщик", ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task EnsureUserAsync(
|
||||||
|
AppDbContext db, UserManager<User> userMgr,
|
||||||
|
Guid orgId, string email, string fullName,
|
||||||
|
string identityRole, Guid employeeRoleId, string position,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var user = await userMgr.FindByEmailAsync(email);
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
user = new User
|
||||||
|
{
|
||||||
|
UserName = email,
|
||||||
|
Email = email,
|
||||||
|
EmailConfirmed = true,
|
||||||
|
FullName = fullName,
|
||||||
|
OrganizationId = orgId,
|
||||||
|
IsActive = true,
|
||||||
|
};
|
||||||
|
var r = await userMgr.CreateAsync(user, "Admin12345!");
|
||||||
|
if (!r.Succeeded) return;
|
||||||
|
await userMgr.AddToRoleAsync(user, identityRole);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Поддерживаем IsActive=true, OrganizationId, роль — чтобы если
|
||||||
|
// кто-то их вручную потрогал, при рестарте всё вернулось в норму.
|
||||||
|
var dirty = false;
|
||||||
|
if (!user.IsActive) { user.IsActive = true; dirty = true; }
|
||||||
|
if (user.OrganizationId != orgId) { user.OrganizationId = orgId; dirty = true; }
|
||||||
|
if (dirty) await userMgr.UpdateAsync(user);
|
||||||
|
|
||||||
|
var roles = await userMgr.GetRolesAsync(user);
|
||||||
|
if (!roles.Contains(identityRole))
|
||||||
|
await userMgr.AddToRoleAsync(user, identityRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasEmployee = await db.Employees.IgnoreQueryFilters()
|
||||||
|
.AnyAsync(e => e.OrganizationId == orgId && e.UserId == user.Id, ct);
|
||||||
|
if (!hasEmployee)
|
||||||
|
{
|
||||||
|
db.Employees.Add(new Employee
|
||||||
|
{
|
||||||
|
OrganizationId = orgId,
|
||||||
|
UserId = user.Id,
|
||||||
|
LastName = fullName.Split(' ').Last(),
|
||||||
|
FirstName = fullName.Split(' ').First(),
|
||||||
|
Position = position,
|
||||||
|
Email = email,
|
||||||
|
RoleId = employeeRoleId,
|
||||||
|
IsActive = true,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue