Ava3v.WebApi.Core (0.8.3)
Installation
dotnet nuget add source --name Administrator --username your_username --password your_token https://git.ava3v.ru/api/packages/Administrator/nuget/index.jsondotnet add package --source Administrator --version 0.8.3 Ava3v.WebApi.CoreAbout this package
Общее веб-ядро ava3v: minimal-API → MediatR (CQRS) с билдерами (GET/POST/PUT/DELETE/file/form-data), базовые команды/запросы/хендлеры, ValidationBehavior (FluentValidation), единый маппинг ошибок в HTTP, локализация, контексты (correlation/current-user), JWT. Извлечено из WareHub.
Ava3vLibs
Монорепозиторий общих .NET-библиотек ava3v.
- Общая версия пакетов —
<Version>вDirectory.Build.props(одна на всю монорепу). - Версии внешних зависимостей — в
Directory.Packages.props(Central Package Management), меняются в одном месте. - Приватный NuGet-фид:
https://git.ava3v.com/api/packages/Administrator/nuget.
Пакеты (зависимость строго вниз)
| Пакет | Зависит от | Назначение |
|---|---|---|
| Ava3v.Abstractions | — | Базовые контракты (ноль зависимостей): интерфейсы сущностей (IHasId/IHasParentId/IHasChildrenInfo и др.), EntityBase, DTO, Enumeration/UserKindEnum, контексты, Security, PagedResponse, паджинг-запросы (IPagedQuery/IHasSearch/ITreePagedQuery), ошибки бизнес-логики. |
| Ava3v.Components.Contract | Abstractions (+EFCore) | Грид-лоадер (фильтры/сортировка/поиск/уникальные значения по IQueryable) + построитель древовидного реестра. |
| Ava3v.WebApi.Core | Abstractions (+MediatR/AspNetCore/…) | minimal-API → MediatR (CQRS): билдеры (GET/POST/PUT/DELETE/file/form-data), middleware, локализация, хендлер-базы, JWT. |
| Ava3v.DataAccess | Abstractions (+EFCore.Relational) | Базовый DbContext (аудит/soft-delete/период действия), snake_case-конфиг сущностей, query-фильтры. |
Публикация
Токен Gitea (personal access token с правом write:package) держи у себя — в репо не коммитим.
Перед публикацией новой версии подними <Version> в Directory.Build.props (одну и ту же версию Gitea второй раз не примет; --skip-duplicate это проглотит).
Всё сразу (обычный путь)
cd ~/Ava3vLibs
rm -rf artifacts
dotnet pack Ava3vLibs.slnx -c Release -o artifacts
dotnet nuget push "artifacts/*.nupkg" \
--source https://git.ava3v.com/api/packages/Administrator/nuget \
--api-key <твой-gitea-токен> \
--skip-duplicate
По отдельности
Порядок важен, только если фид пустой:
Ava3v.Abstractionsпубликуй первым — остальные от него зависят.dotnet packлюбого проекта складывает.nupkgв общийartifacts/.
Общий шаблон:
cd ~/Ava3vLibs
PROJ=Ava3v.Abstractions # или Ava3v.Components.Contract / Ava3v.WebApi.Core / Ava3v.DataAccess
dotnet pack src/$PROJ/$PROJ.csproj -c Release -o artifacts
dotnet nuget push "artifacts/$PROJ.*.nupkg" \
--source https://git.ava3v.com/api/packages/Administrator/nuget \
--api-key <твой-gitea-токен> \
--skip-duplicate
Конкретно по каждому:
1. Ava3v.Abstractions (сначала)
dotnet pack src/Ava3v.Abstractions/Ava3v.Abstractions.csproj -c Release -o artifacts
dotnet nuget push "artifacts/Ava3v.Abstractions.*.nupkg" --source https://git.ava3v.com/api/packages/Administrator/nuget --api-key <токен> --skip-duplicate
2. Ava3v.Components.Contract
dotnet pack src/Ava3v.Components.Contract/Ava3v.Components.Contract.csproj -c Release -o artifacts
dotnet nuget push "artifacts/Ava3v.Components.Contract.*.nupkg" --source https://git.ava3v.com/api/packages/Administrator/nuget --api-key <токен> --skip-duplicate
3. Ava3v.WebApi.Core
dotnet pack src/Ava3v.WebApi.Core/Ava3v.WebApi.Core.csproj -c Release -o artifacts
dotnet nuget push "artifacts/Ava3v.WebApi.Core.*.nupkg" --source https://git.ava3v.com/api/packages/Administrator/nuget --api-key <токен> --skip-duplicate
4. Ava3v.DataAccess
dotnet pack src/Ava3v.DataAccess/Ava3v.DataAccess.csproj -c Release -o artifacts
dotnet nuget push "artifacts/Ava3v.DataAccess.*.nupkg" --source https://git.ava3v.com/api/packages/Administrator/nuget --api-key <токен> --skip-duplicate
Закоммитить исходники
git add -A
git commit -m "Ava3vLibs <version> — <что изменилось>"
git push
Как это потребляют другие проекты
В nuget.config потребителя добавить источник (один раз):
<packageSources>
<add key="ava3v" value="https://git.ava3v.com/api/packages/Administrator/nuget/index.json" />
</packageSources>
Затем нужные пакеты (транзитивно тянут Ava3v.Abstractions):
<PackageReference Include="Ava3v.WebApi.Core" Version="0.2.0" />
<PackageReference Include="Ava3v.Components.Contract" Version="0.2.0" />
<PackageReference Include="Ava3v.DataAccess" Version="0.2.0" />
Интервалы дат в хедер-фильтре грида (Ava3v.Components.Contract 0.8.3+)
GroupInterval (year/quarter/month/day) у UniqueValues и ColumnFilters работает для полей DateTime,
DateTimeOffset, DateOnly и их nullable: уникальные значения — токены (2026, 2026-Q3, 2026-09,
2026-09-16), фильтр — полуинтервалы [start, next) по UTC, null в значениях — «пусто». Усечение и границы
уходят в SQL (Postgres: timestamptz и date). Другой тип поля с GroupInterval — 400
grid_date_interval_unsupported. Группировка грида интервалов не использует (ключ — сырое значение).
Массовые операции по выделению грида (Ava3v.Components.Contract 0.2.33+)
Грид с 0.1.158 умеет «выделить всё по фильтру»: на сервер приходит не список ключей, а
GridSelectionRequest — флаг AllMatchingSelected, ExcludedKeys и Filter (тело
getBackendRequest() грида) либо SelectedKeys для явного выделения. Разрешение в запрос
и предохранитель — GridSelectionResolver:
// Команда: { GridSelectionRequest Selection; ... patch ... }
var rows = await db.Items.ResolveAsync(command.Selection, x => x.Id, maxAffectedRows: 10_000, ct);
// > лимита — BusinessLogicException с Code = "BulkLimitExceeded" ДО изменения данных (COUNT до лимита + 1)
await rows.ExecuteUpdateAsync(s => s.SetProperty(x => x.Status, command.Status), ct);
ApplySelection — только запрос без проверки, EnsureWithinLimitAsync — только проверка.
Лимит держите согласованным с фронтовым selectAllMatchingLimit (по умолчанию 10 000).
Заметки к выпуску
Полный список — в CHANGELOG.md.
0.5.0 — refresh-токены и сессии (аддитивно)
Ava3v.Abstractions.Sessions: контрактыIAva3vRefreshTokenStore,IAva3vClaimsProvider,IAva3vUserStateProvider,IAva3vSessionEvents, коды отказаAva3vSessionErrorCodes(invalid_refresh,refresh_reused,session_mismatch,user_blocked,user_inactive,refresh_expired,session_revoked),Ava3vSessionException(401 + код).Ava3v.DataAccess.Sessions: сущностьAva3vRefreshToken(в базе только SHA-256 отпечаток),ApplyAva3vRefreshTokens(schema),AddAva3vRefreshTokenStore<TContext>(); ротация атомарна (UPDATE … WHERE revoked_at IS NULL + вставка преемника в одной транзакции).Ava3v.WebApi.Core.Web.Sessions:IAva3vSessionService(StartSession / Refresh / Logout / RevokeSession / RevokeAllSessions / ListSessions),IAva3vRevokedSessionListнаIDistributedCache(с Redis — общий между экземплярами),IAva3vAuthCookieService(httpOnly, отдельный путь refresh), проверка отозванных sid и (по настройке) состояния пользователя вOnTokenValidated,AddAva3vSessions(configuration). Секции:Jwt:Sessions(сроки по виду пользователя, окно гонки, реакция на повтор),AuthCookie.- Протокол: refresh одноразовый; владелец сессии — запись токена; sid сохраняется при ротации; гонка вкладок (живой преемник в окне 60 с) — пара под тем же sid; повтор вне окна — отзыв всех сессий пользователя и гашение их access-токенов (настраивается), событие для аудита; абсолютный предел переносится при ротации.
- Тесты: Sqlite (протокол, куки, состояние с кешем) и Postgres (гонка четырёх одновременных запросов с одним токеном, повтор между двумя экземплярами с общим списком отозванных).
0.4.0 — по замечаниям Cargo (ЛОМАЮЩЕЕ для колонок)
- snake_case для колонок, ключей, внешних ключей и индексов — конвенция
Ava3vSnakeCaseConventionвключена вAva3vDbContextBaseпо умолчанию (таблицы и раньше —ToTableInSnakeCase). ЯвныеHasColumnName/HasName/ToTableне перебиваются. Потребителям на базовом контексте нужна новая миграция (колонки переименовываются); прежнее поведение —protected override bool UseSnakeCaseNaming => false. Переезд сEFCore.NamingConventions: та библиотека переименовывала и служебную__EFMigrationsHistory(migration_id/product_version), аAva3vSnakeCaseConvention— модельная и историю миграций не трогает, EF спрашивает"MigrationId". На существующей базе после снятияUseSnakeCaseNamingConvention()старт падает42703: column "MigrationId" does not exist; на чистой базе всё согласовано, поэтому ловится уже на боевой. Один раз руками, без пересоздания и новой миграции:ALTER TABLE "__EFMigrationsHistory" RENAME COLUMN migration_id TO "MigrationId"; ALTER TABLE "__EFMigrationsHistory" RENAME COLUMN product_version TO "ProductVersion"; ALTER TABLE "__EFMigrationsHistory" RENAME CONSTRAINT pk___ef_migrations_history TO "PK___EFMigrationsHistory"; AddAva3vJwtAuthenticationидемпотентен: повторный вызов рядом сAddAva3vCoreWebApi(который уже включает JWT) больше не роняет старт «Scheme already exists: Bearer».Ava3vJwtOptionsрегистрируется в DI (сам класс иIOptions<Ava3vJwtOptions>) — один источник истины для ключа/issuer/audience; новая настройкаJwt:AccessTokenLifetimeMinutes(15).IAva3vTokenIssuer(режимы Symmetric/Both):IssueAccessToken(claims, lifetime?)иGenerateRefreshToken()теми же параметрами, что и проверка. Клеймы — за продуктом.- Двухдженериковые
MapPostMediatrRequest/MapPutMediatrRequest(запрос из тела) с плейсхолдером в маршруте падают на старте понятной ошибкой вместо тихогоdefaultв параметре — берите перегрузку<TRequestParams, TRequest, TResponse>.
0.3.0 — ЛОМАЮЩЕЕ: авторство при добавлении
Ava3vDbContextBase:CreatedByпри добавлении ВСЕГДА берётся из текущего пользователя, заданное значение (в том числе прилетевшее из DTO через маппер) игнорируется. Раньше заполненныйCreatedByне трогался — клиент мог записать запись от чужого имени.- Сохранить исходного автора можно только из кода, явным режимом с областью действия:
using (context.PreserveAuthorship()) { … await context.SaveChangesAsync(); }— для сидеров, генераторов демо-данных, переноса и синхронизации данных. В режимеCreatedBy = 0всё равно заменяется текущим пользователем;UpdatedBy/DeletedByрежим не затрагивает; автор записи журнала аудита — всегда тот, кто выполняет операцию.CreatedAtэто решение не трогает. - Что проверить у себя: все места, где
CreatedByзадаётся явно (сидеры, импорты, DevData, генераторы, вызываемые через API — там автором станет вызвавший), обернуть в режим. - Аудит: тесты на настоящем Postgres (
tests/Ava3v.Audit.PostgresTests, Testcontainerspostgres:14.1либоTEST_POSTGRES_CONNECTION); без Docker локально — Skipped, в CI (CI=true) — падение.
Сессии и refresh-токены (Ava3v.WebApi.Core + Ava3v.DataAccess, 0.5.0+)
// DI
services.AddAva3vCoreWebApi(configuration); // JWT + IAva3vTokenIssuer
services.AddAva3vRefreshTokenStore<AppDbContext>(); // таблица: modelBuilder.ApplyAva3vRefreshTokens("auth")
services.AddAva3vSessions(configuration); // сервис, куки, транспорт, список отозванных sid
services.AddScoped<IAva3vClaimsProvider, AppClaimsProvider>(); // состав клеймов — продукт (sid, id, user_kind добавит либа)
services.AddScoped<IAva3vUserStateProvider, AppUserStateProvider>(); // существует/активен/не заблокирован — по (вид, id)
services.AddScoped<IAva3vSessionEvents, SecurityJournal>(); // по желанию: reuse_detected, session_revoked
services.AddStackExchangeRedisCache(...); // на нескольких экземплярах; иначе память процесса
app.UseAuthentication(); app.UseAva3vSessions(); app.UseAuthorization(); // CSRF + предупреждения старта
// Логин (проверка пароля и попыток — в продукте); пользователь = (вид, id)
var user = new Ava3vUserKey("Internal", account.Id);
var tokens = await sessions.StartSessionAsync(user, Ava3vSessionClient.FromRequest(Request));
await responder.WriteAsync(Response, tokens, responder.DetectTransport(Request)); // Cookie или Body
// Обновление: refresh из тела (программный клиент) или из куки (браузер), access — заголовок или кука
var refresh = Ava3vSessionTokenReader.ReadRefresh(Request, cookies, body?.RefreshToken);
var pair = await sessions.RefreshAsync(refresh!, Ava3vSessionTokenReader.ReadAccess(Request, cookies), client);
await responder.WriteAsync(Response, pair, responder.DetectTransport(Request));
// Выход
await sessions.LogoutAsync(refresh, client); cookies.Clear(Response);
// Блокировка / удаление / смена пароля — только этот вид пользователя
await sessions.RevokeAllSessionsAsync(user, "password_changed");
Браузер и программные клиенты
- Браузер: httpOnly-куки access (путь
/) и refresh (путьAuthCookie:RefreshPath), рядом не-httpOnlyava3v_csrf. На POST/PUT/PATCH/DELETE фронт кладёт значениеava3v_csrfв заголовокX-CSRF-Token, иначе 403csrf_failed. Обновление — тоже POST с кукой и CSRF-заголовком. CSRF-значение живёт, пока живёт кука: refresh и «текущий пользователь» его не меняют (иначе вторая вкладка ловила бы 403), новое — только когда куки нет. - Перезагрузка страницы на другом хосте с истёкшим access (0.8.3):
app.MapAva3vCsrf()— анонимныйGET api/auth/csrfотдаёт CSRF-значение (кука + заголовок +csrfTokenв теле); фронт берёт его до refresh. С кукой в запросе возвращает то же значение, соседние вкладки не ломаются. - Где проверяется CSRF (0.8.2): не отдельным middleware, а там, где кука становится полномочием —
в аутентификации для токена из куки (после проверки самого токена: истёкшая или отозванная кука даёт
свой 401, а не 403) и в
ReadRefreshToken/ReadAccessTokenдля refresh и выхода. Анонимный вход с лежащей в браузере старой кукой не блокируется; отказ уходит послеUseCors— с CORS-заголовками. Свой эндпоинт, читающий куки напрямую, проверяетcookies.IsCsrfValid(Request)сам.app.UseAva3vSessions()необязателен: только предупреждения старта, безопасен при повторе. - Порядок 401/403 для куки: истёк →
token_expired; сессия отозвана →session_revoked; токен безsidпри включённых сессиях →token_expired(выпущен до сессий, отозвать нечем — обновитесь); всё в порядке, но нет CSRF на POST → 403csrf_failed. - Программный клиент: логин →
Transport=Body(заголовокX-Token-Transport: bodyлибо запрос уже сAuthorization: Bearer) → токены в JSON; дальшеAuthorization: Bearer, refresh — значение в теле запроса. CSRF не проверяется, куки не ставятся. - Кука и заголовок в одном запросе — считается заголовок.
- Фронт и API на разных хостах (без прокси): CSRF-значение фронт берёт НЕ из куки (чужой хост её не
отдаст), а из тела ответа логина/refresh (
csrfToken) либо заголовка ответаX-CSRF-Token; при перезагрузке страницы — из ответа «текущий пользователь» (cookies.IssueCsrfToken(Response)). На API:services.AddAva3vCors(configuration)+app.UseAva3vCors()до UseAuthentication иCors:AllowedOrigins: ["https://warehub.ava3v.ru"]; фронт шлёт запросы сcredentials: 'include'. Хосты должны быть same-site (поддомены одного домена), иначе SameSite=Strict не пропустит куки. - SignalR:
Jwt:QueryTokenPaths: ["/hub"]—?access_token=принимается только на этих путях. - Коды 401:
auth_required(нет токена),token_expired(обновись; плюс заголовокJwt:ExpiredHeaderName),session_revoked,user_blocked,user_inactive. Refresh:invalid_refresh,refresh_reused,session_mismatch,refresh_expired. - Режим
Both: свой токен в заголовке проходит поiss, внешний — на внешнюю схему. Настройки:
"Jwt": { "AccessTokenLifetimeMinutes": 15,
"ExpiredHeaderName": "IS-TOKEN-EXPIRED", "QueryTokenPaths": ["/hub"],
"Sessions": { "DefaultPolicy": { "IdleMinutes": 720, "AbsoluteMinutes": 43200, "RaceWindowSeconds": 60, "ReuseReaction": "RevokeAllSessions" },
"Policies": { "External": { "IdleMinutes": 120, "AbsoluteMinutes": 480 },
"Integration": { "ReuseReaction": "RevokeSession" } },
"CheckUserStateOnEachRequest": false, "UserStateCacheSeconds": 60 } },
"Maintenance": { "IntervalMinutes": 60, "RefreshTokenRetentionDays": 30, "ApiKeyRetentionDays": 90 },
"AuthCookie": { "AccessTokenName": "ava3v_access_token", "RefreshTokenName": "ava3v_refresh_token",
"RefreshPath": "/api/auth", "SameSite": "Strict", "Secure": true,
"Csrf": { "Enabled": true, "CookieName": "ava3v_csrf", "HeaderName": "X-CSRF-Token" } }
Переход с хранения токена как есть на отпечатки — однократный разлогин всех пользователей (старые записи не подходят под новую таблицу), переходного режима нет.
Обслуживание: очистка и retention (0.8.0+)
services.AddAva3vMaintenance(configuration); // хост задач; задачи регистрируют AddAva3vSessions/AddAva3vApiKeys/AddAva3vAudit
Раз в Maintenance:IntervalMinutes удаляются отозванные/истёкшие refresh-токены старше
RefreshTokenRetentionDays и API-ключи старше ApiKeyRetentionDays; журнал аудита и снимки — по
Audit:RetentionDays/SnapshotRetentionDays (null — вечно). Без хоста таблицы растут.
Ava3vMaintenanceHostedService.RunOnceAsync() — ручной запуск из кода или тестов.
API-ключи для интеграций (0.7.0+)
services.AddAva3vApiKeyStore<AppDbContext>(); // таблица: modelBuilder.ApplyAva3vApiKeys("auth")
services.AddAva3vApiKeys(configuration); // ПОСЛЕ AddAva3vCoreWebApi: схема X-Api-Key + селектор
// Выдача (ключ показывается один раз), отзыв, список — из админки продукта
var issued = await apiKeys.CreateAsync(new Ava3vUserKey("Service", account.Id), "1c-sync", TimeSpan.FromDays(365));
return issued.Key; // ak_{prefix}_{secret}
Запрос интеграции: X-Api-Key: ak_… — без сессий, refresh и CSRF. Владелец — сервисная учётка
(вид, id); блокировка владельца гасит ключ. Лимит живых ключей ApiKeys:MaxActivePerOwner (3):
ротация — выдать новый, перевести клиента, отозвать старый.
Переезд без разлогина (по желанию, 0.7.0+)
new Ava3vRefreshTokenImport(store).ImportAsync(rows) один раз при выкатке: прежние refresh
становятся записями с отпечатком и новым sid. Старые access-токены без sid/user_kind 0.6.0 не
принимает — клиент сделает refresh и получит нормальную пару. Без импорта — однократный разлогин.
Аудит (Ava3v.Audit, 0.3.0+)
Журнал изменений по полям и снимки версий в транзакции самих изменений. Контракты — в
Ava3v.Abstractions.Audit, реализация — в пакете Ava3v.Audit. Подключение:
// DI
services.AddAva3vAudit<AppDbContext>(o => { o.Schema = "audit"; o.WriteSnapshots = true; });
services.AddDbContext<AppDbContext>((sp, o) => o.UseNpgsql(cs).UseAva3vAudit(sp));
// Своё: описания полей, резолверы ссылок, форматтеры, скоуп тенанта, политика доступа, имена авторов
services.AddScoped<IAuditFieldDescriptorProvider, IssueAuditFields>();
services.AddScoped<IAuditReferenceResolver, UserReferenceResolver>(); // читать через IgnoreQueryFilters!
services.AddScoped<IAuditScopeProvider, WorkspaceAuditScope>(); // TenantId/ScopeId + фильтр чтения
services.AddScoped<IAuditAccessPolicy, EntityAuditAccessPolicy>(); // без неё — любому аутентифицированному
services.AddScoped<IAuditUserNameResolver, UserNameResolver>();
// Контекст: наследник Ava3vDbContextBase (штамп метаданных и мягкое удаление — до аудита)
protected override void OnModelCreating(ModelBuilder b) { base.OnModelCreating(b); b.ApplyAva3vAudit(auditOptions); }
public bool SuppressAudit { get; set; } // IAuditSuppressible — миграции и массовая загрузка
// Эндпоинт истории: GET api/audit/getShort?entityType=Issue&entityId=5&skip=0&take=20
app.MapAva3vAudit();
// Программно: IAuditLogService.GetShortAsync(...), IEntitySnapshotService.GetAsOfAsync<T>(id, moment)
Правила:
- контекст регистрируется
AddDbContext, неAddDbContextPool: интерсептор scoped и хранит буферы одного сохранения, пул контекстов требует singleton-интерсептора без состояния; - журнал пишется вторым SaveChanges того же контекста в той же транзакции; без явной транзакции интерсептор открывает свою и коммитит после журнала; откат/ошибка/отмена журнал не оставляют;
EnableRetryOnFailureнесовместим с собственной транзакцией: оборачивайте сохранения вCreateExecutionStrategy().ExecuteAsync(...)(интерсептор бросает понятную ошибку, если этого не сделать), либоUseOwnTransaction = false;ExecuteUpdate/ExecuteDeleteобходят и метаданные, и мягкое удаление, и журнал — в проекте нужен архитектурный тест с разрешённым списком таких вызовов;- мягкое удаление пишется как
Deleted; lifecycle-поля,[AuditIgnore],INotAuditableне пишутся; - автор записи — текущий пользователь (не
CreatedByсущности); ключ сущности —long; - таблицы
{schema}.audit_logи{schema}.entity_snapshot— миграция на стороне потребителя.