Ava3v.Abstractions (0.5.0)
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.5.0 Ava3v.AbstractionsAbout this package
Базовые контракты ava3v (ноль зависимостей): интерфейсы сущностей (IHasId/IHasParentId/IHasChildrenInfo и др.), EntityBase, DTO, перечисления (Enumeration/UserKindEnum), контексты, права (Security), страничный ответ PagedResponse, паджинг-запросы (IPagedQuery/IHasSearch/ITreePagedQuery), ошибки бизнес-логики. Извлечено из 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.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).
Заметки к выпуску
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. 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>(); // состав клеймов — продукт
services.AddScoped<IAva3vUserStateProvider, AppUserStateProvider>(); // существует/активен/не заблокирован
services.AddScoped<IAva3vSessionEvents, SecurityJournal>(); // по желанию: reuse_detected, session_revoked
services.AddStackExchangeRedisCache(...); // на нескольких экземплярах; иначе память процесса
app.Services.WarnIfRevokedListIsLocal();
// Логин (проверка пароля и попыток — в продукте)
var tokens = await sessions.StartSessionAsync(user.Id, user.UserKind, new Ava3vSessionClient(ip, ua));
cookies.SetSession(Response, tokens);
// Обновление
var pair = await sessions.RefreshAsync(cookies.ReadRefreshToken(Request)!, cookies.ReadAccessToken(Request), client);
// Выход
await sessions.LogoutAsync(cookies.ReadRefreshToken(Request), client); cookies.Clear(Response);
// Блокировка / удаление / смена пароля
await sessions.RevokeAllSessionsAsync(userId, "password_changed");
Настройки:
"Jwt": { "AccessTokenLifetimeMinutes": 15,
"Sessions": { "DefaultLifetime": { "IdleMinutes": 720, "AbsoluteMinutes": 43200 },
"Lifetimes": { "External": { "IdleMinutes": 120, "AbsoluteMinutes": 480 } },
"RaceWindowSeconds": 60, "ReuseReaction": "RevokeAllSessions",
"CheckUserStateOnEachRequest": false, "UserStateCacheSeconds": 60 } },
"AuthCookie": { "AccessTokenName": "ava3v_access_token", "RefreshTokenName": "ava3v_refresh_token",
"RefreshPath": "/api/auth", "SameSite": "Strict" }
Переход с хранения токена как есть на отпечатки — однократный разлогин всех пользователей (старые записи не подходят под новую таблицу), переходного режима нет.
Аудит (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)
Правила:
- журнал пишется вторым SaveChanges того же контекста в той же транзакции; без явной транзакции интерсептор открывает свою и коммитит после журнала; откат/ошибка/отмена журнал не оставляют;
EnableRetryOnFailureнесовместим с собственной транзакцией: оборачивайте сохранения вCreateExecutionStrategy().ExecuteAsync(...)(интерсептор бросает понятную ошибку, если этого не сделать), либоUseOwnTransaction = false;ExecuteUpdate/ExecuteDeleteобходят и метаданные, и мягкое удаление, и журнал — в проекте нужен архитектурный тест с разрешённым списком таких вызовов;- мягкое удаление пишется как
Deleted; lifecycle-поля,[AuditIgnore],INotAuditableне пишутся; - автор записи — текущий пользователь (не
CreatedByсущности); ключ сущности —long; - таблицы
{schema}.audit_logи{schema}.entity_snapshot— миграция на стороне потребителя.