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*-*,
|
||||
a11y-*, mob1*-*, isoB*-*, axdet-*, smoke-*, ratetest-*, perf-*, probe-*,
|
||||
load-*, s19-test-* и всё с суффиксом из ≥10 цифр) — скрыты. Лимит 50 + `?q=`.
|
||||
- [ ] 4. **Демо-аккаунты** — идемпотентный сид «Демо-магазин» (через
|
||||
- [x] 4. **Демо-аккаунты** — идемпотентный сид «Демо-магазин» (через
|
||||
существующий `DemoTenantSeeder`) + сотрудники с ролями Admin / Cashier /
|
||||
Storekeeper (`demo-admin@food-market.local`, `demo-cashier@...`,
|
||||
`demo-keeper@...`). Запускается только при Enabled=true.
|
||||
|
|
|
|||
|
|
@ -451,6 +451,9 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
|||
builder.Services.AddHostedService<DevDataSeeder>();
|
||||
// Демо-сидер для stage'a (триггерится через POST /api/admin/seed-demo).
|
||||
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).
|
||||
builder.Services.AddScoped<foodmarket.Api.Seed.YearDemoSeeder>();
|
||||
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