food-market/src/food-market.api/Controllers/Admin/AdminJobsController.cs
nurdotnet ce0c3acdd6 feat(other-system-import): async jobs с прогрессом + токен в настройках
Pain points:
1. Импорт на ~30k товарах проходит 15-30 мин, nginx рвал на 60s → 504.
2. При импорте/очистке ничего не видно — ни счётчика, ни прогресса.
3. Токен приходилось вводить каждый раз вручную.

Фиксы:
- Async-job pattern: POST /api/admin/other-system/import-products и
  /api/admin/cleanup/all/async возвращают jobId, реальная работа
  в Task.Run. GET /api/admin/jobs/{id} — статус +
  Total/Created/Updated/Skipped/Deleted/Stage/Message.
- ImportJobRegistry (singleton, in-memory) — хранит job-progress.
- OtherSystemImportService обновляет progress по мере пейджинга
  (в т.ч. счётчик Created/Updated/Skipped).
- Cleanup разбит на именованные шаги, Stage меняется по мере
  "Товары…" → "Группы…" → "Контрагенты…".
- Токен per-organization: Organization.OtherSystemToken + миграция
  Phase3_OrganizationOtherSystemToken. Endpoints:
  GET/PUT /api/admin/other-system/settings.
- Импорт-endpoints больше не требуют token в теле — берут из org.
- HttpContextTenantContext.UseOverride(orgId) — AsyncLocal-scope
  для background tasks (HttpContext там нет, а query-filter'у нужен
  orgId — ставим через override).

Nginx (host + web-container) получил 60-минутный timeout на
/api/admin/import/ чтобы старый sync-путь тоже не ронять (на
случай если кто-то вернёт sync call).

Web:
- OtherSystemImportPage переработан: блок "Токен API" (save/test
  mask), блок импорта с кнопками без поля токена.
- JobCard с polling каждые 1.5s отображает живые счётчики и stage.
- DangerZone тоже теперь async с live-прогрессом.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:49:11 +05:00

42 lines
1.3 KiB
C#

using foodmarket.Infrastructure.Integrations.MoySklad;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace foodmarket.Api.Controllers.Admin;
[ApiController]
[Authorize(Policy = "AdminAccess")]
[Route("api/admin/jobs")]
public class AdminJobsController : ControllerBase
{
private readonly ImportJobRegistry _jobs;
public AdminJobsController(ImportJobRegistry jobs) => _jobs = jobs;
public record JobView(
Guid Id,
string Kind,
string Status,
string? Stage,
DateTime StartedAt,
DateTime? FinishedAt,
int Total, int Created, int Updated, int Skipped, int Deleted, int GroupsCreated,
string? Message,
IReadOnlyList<string> Errors);
private static JobView Project(ImportJobProgress j) => new(
j.Id, j.Kind, j.Status.ToString(), j.Stage, j.StartedAt, j.FinishedAt,
j.Total, j.Created, j.Updated, j.Skipped, j.Deleted, j.GroupsCreated,
j.Message, j.Errors.TakeLast(20).ToList());
[HttpGet("{id:guid}")]
public ActionResult<JobView> Get(Guid id)
{
var j = _jobs.Get(id);
return j is null ? NotFound() : Project(j);
}
[HttpGet("recent")]
public IReadOnlyList<JobView> Recent([FromQuery] int take = 10)
=> _jobs.RecentlyFinished(take).Select(Project).ToList();
}