Cabinet
NOTE
Статус: Live (HEAD
5e4ce54, 2026-05-06). Base BC + 4 sub-BC уже работают: profile, customer, apikeys, credentials. Остаётся развитие UI (subscription, stats, oauth) и I4b/I4c keys-features (usage counter, IP allowlist).
Этот README описывает зонтичный BC cabinet, обслуживающий пользовательский личный кабинет Tracium (/cabinet/* UI + /api/cabinet/* HTTP). Читайте, когда нужно понять, какие подсистемы кабинета есть, как они взаимодействуют и что осталось до полного roadmap’а.
Назначение
Самообслуживание customer’а: профиль организации, выпуск API-ключей, управление учётными записями поставщиков (customer credentials), будущие screens — подписка, статистика, журнал событий, OAuth-привязки.
Статус документа
- Тип знания:
current service - Статус реализации: 4 sub-BC в
backend/internal/core/cabinet/{apikeys,credentials,customer,profile}+ UI вweb/landing/src/app/cabinet/* - Текущее место кода:
backend/internal/core/cabinet/apikeys— HMAC-SHA-256 token storage + reveal-once + rotate + scopes/role + admin CRUD (I4a + RBAC Phase 2)backend/internal/core/cabinet/customer— Customer entity (multi-owner, archive)backend/internal/core/cabinet/profile— single-table customer_profile + atomic-outbox PG repo (I1)backend/internal/core/cabinet/credentials/supplier_catalog— Go-const SupplierMeta для 5 поставщиков (ETM/IEK/Systeme/DKC/Russvet)- Customer credential CRUD —
backend/internal/core/credentials/api/http/handler.go(clientBasePath/api/v1/credentials) - UI —
web/landing/src/app/cabinet/{profile,keys,credentials,oauth,stats,subscription,security,archived}
- Что читать дальше:
../../20-architecture/adr/0051-public-api-auth-and-api-key-rbac.md,../../20-architecture/adr/0041-credentials-pool-and-secrets-isolation.md,../public-api/README.md
Область действия
Входит:
- HTTP API под
/api/cabinet/*(JWT-cookie auth через nginxauth_request). - Профиль customer’а (юр.лицо, контакты, налоговые реквизиты).
- Выпуск/ротация/блокировка API-ключей с scope-системой (
whoami/products:read/search:readсейчас active;orders:write/credentials:*planned). - CRUD customer credentials для поставщиков (5 supported: ETM/IEK/Systeme/DKC/Russvet).
- Правила цен клиента: список, карточка правила, жизненный цикл (активация/пауза/архив) и экран проверки правил на товаре (
/cabinet/pricing-rules/playground). - Архивирование (soft-delete) аккаунта.
Не входит:
- Public API (
/v1/*) — отдельный edge-слой, JWT не принимает (ADR-0051). См.../public-api/README.md. - Admin API (
/api/v1/admin/*) — отдельный flow для платформы; admin может выпускать ключи за customer’ов, но это admin BC. - Биллинг и платёжный шлюз — пока не реализовано, planned.
- OAuth-привязки (Google/Yandex/email-link) — planned, ComingSoon screen.
Публичный контракт
Вход
HTTP (cabinet, JWT-cookie):
| Метод + путь | Подсистема |
|---|---|
GET /api/cabinet/profile / PATCH /api/cabinet/profile / POST /api/cabinet/profile/switch-type / POST /api/cabinet/profile/archive | profile + customer |
GET /api/cabinet/keys / POST /api/cabinet/keys / GET/PATCH/DELETE /api/cabinet/keys/{id} / POST /api/cabinet/keys/{id}/rotate / POST /api/cabinet/keys/{id}/revoke / GET /api/cabinet/scopes/catalog | apikeys |
GET /api/cabinet/credentials/suppliers/catalog | credentials.supplier_catalog |
GET/POST /api/v1/credentials / GET/PATCH /api/v1/credentials/{id}/(secret|status) | credentials (customer-scope) |
Internal:
GET /_internal/auth/api-key/verify— nginxauth_requestsubrequest (используется public API path; не cabinet-side).
Выход
- Outbox events для audit:
cabinet.apikey.{created,revoked,rotated,renamed,deleted}.v1- Profile change events.
- Чтение customer credentials publish’ится через secret-resolver в ingestion BC (per-credential SessionManager).
Внутренняя архитектура
Каждый sub-BC — clean architecture (api/http/, app/, domain/, infra/pg/).
- apikeys — самый сложный: HMAC pepper, 31-char
trk_live_*/trk_test_*tokens, reveal-once UX, rotation (atomic single-tx), scopes/role с D9 active-vs-planned гейтом, admin-flow сcreated_by_adminaudit field. - profile — debounced auto-save, optimistic concurrency через
customer_version. - customer — owner-member graph, archive не удаляет данные.
- credentials.supplier_catalog — Go-const SupplierMeta (5 поставщиков); add 6-го требует release backend’а.
- pricing-rules UI — экраны живут в
web/landing/src/app/cabinet/pricing-rules/*, общие компоненты вынесены в пакет@tracium/pricing-rules(web/shared/pricing-rules), чтобы админка и кабинет не расходились в трактовке статусов.
Проверка правил на товаре (playground)
Экран /cabinet/pricing-rules/playground намеренно не считает цену сам. Он вызывает боевой POST /v1/search/proposals — тот же эндпоинт и ту же авторизацию, которыми пользуется интеграция клиента, — и показывает пришедший adjustments[] по шагам. Совпадение с тем, что увидит внешний клиент, обеспечено не повторением формул, а тем, что это один и тот же вызов; отдельный «песочный» расчёт доказывал бы работу песочницы, а не продукта.
Товар выбирается поиском по названию, а не вводом идентификатора: у клиента на руках названия и артикулы, внутренних UUID каталога он не видит нигде. Подсказки даёт GET /v1/products/suggest — тот же ES-typeahead, которым пользуется админка, — открытый сессии кабинета точечно:
- nginx:
location = /v1/products/suggest(точное совпадение бьёт префикс/v1/products), диспетчер bearer/cookie, cookie-ветка черезid.conf. Список, карточка,resolveи structured search остаются bearer-only; - Go: у этого одного маршрута обёртка
RequireScopeOrLegacyJWTFor("products:read", …)вместоRequireScope— иначе сессия без заголовковX-Tr-*была бы отвергнута уже обработчиком.
Почему suggest, а не /v1/products/search. Structured search живёт в Postgres, и по одному свободному тексту его запрос вырождается в полный перебор: ILIKE с ведущим % (индексом не берётся), коррелированный EXISTS в supplier_offers на каждую строку и ORDER BY cp.name. На 1,28 млн канонов это минуты — замер прода 2026-08-17 дал 504 по таймауту шлюза. Подсказки обслуживает Elasticsearch: тот же запрос по названию — около секунды. Structured search остаётся тем, чем задуман: поиском внутри категории с требованиями к характеристикам, где candidate_ids сужает выборку заранее.
В подсказке показываются название, бренд, SKU, поставщик и наличие. Характеристик там нет и быть пока не может: их не отдаёт ни suggest, ни structured search, а признак значимости в публичном контуре не выставлен вовсе. Вывод значимых осей — отдельная доработка каталога и проекции ES.
Запрос уходит с policy.trace.inline_level = "summary" — это потолок для клиентской сессии (raw требует админского скоупа). На этом уровне шаги приходят полностью, а detail собственных правил клиента не редактируется, поэтому playground может назвать сработавшее правило по имени, а не только показать величину.
Каскад правил и разбор вычисления
Несколько правил одного клиента применяются последовательно: каждое следующее считается от цены, полученной предыдущим (CustomerPriceEngine.ComputeLinePrice, current = total). Но дойдут ли они до применения, решает политика объединения (stacking_policy) — и здесь легко ошибиться:
| политика | что делает |
|---|---|
stack | все подходящие правила применяются подряд, проценты не складываются, а накладываются (10% затем 5% = −14,5%, не −15%) |
best_of | из правил с этой политикой остаётся одно, с наибольшим приоритетом |
priority_wins, exclusive_stop | весь стек схлопывается в ОДНО правило — каскада не будет вовсе |
ResolveStacking проверяет исключающие политики первыми: достаточно одного правила с priority_wins, чтобы остальные не применились. Правило «на всё» с этой политикой блокирует каскад у всего поставщика.
Playground показывает под каждым шагом формулу — ставку, восстановленную из «было» и «стало», базу расчёта, приоритет и политику. Для второго и далее шагов явно сказано, что база — результат предыдущего: именно здесь клиенты ожидают арифметическую сумму процентов и получают другое число.
Процент показывается объявленный, а не восстановленный
Ставка, вычисленная из «было» и «стало», почти всегда чуть меньше объявленной: деньги целочисленные, дельта округляется до копейки. Правило «10%» на позиции за 181,53 ₽ даёт дельту 18,15 ₽ вместо 18,153 ₽, то есть 9,9983% — и клиент читает это как ошибку счёта. Поэтому playground берёт процент из самого правила (version.effect.percent, запрос только по тем rule_id, что попали в разбор), а восстановленный остаётся запасным вариантом, если правило уже заархивировано.
Сумма изменения при этом печатается фактическая: округление — правда, врать про неё нельзя, скрывать проценты — тоже.
Сверка цен: наша база офферов против ответа поставщика
В режиме «Живой запрос» экран считает дважды — живой ответ и то же самое из кэша — и показывает обе цены до правил клиента вместе с расхождением. Без второго числа расхождение не увидеть, а именно оно и есть предмет спора по счёту.
Колонка «Ответ поставщика сейчас» заполняется только при source_mode = live. Если живого запроса не было, там прочерк, а не наше же наблюдение: иначе «Расхождение: совпадает» читалось бы как подтверждение цены поставщиком, которого не происходило.
Два обязательства этого блока:
- причина, по которой живой ответ не использован, выводится словами из
price.live_attempt_reason— и это НЕ догадка. Замер прода 2026-08-18: запрос к поставщику не уходил вовсе, причинаlive_customer_scope_mismatch. Писать в таком случае «поставщик не ответил» значит врать о причине: живой путь разрешён только приCredentialScopeCustomer(livecheck/live_scope_checker.go), системные учётные данные для него не годятся; - наличие СВОИХ учётных данных клиента проверяется через
GET /api/v1/credentialsи пишется рядом с поставщиком. Если своих доступов нет, живой запрос уходит по учётным данным платформы, и такая цена не является контрактной ценой клиента. На проде 2026-08-17 у клиента их ноль, а все шесть записейsupplier_credentialsимеютscope='system'— то есть персональных цен в системе сейчас нет ни у кого.
Обратная запись живого ответа в нашу базу
Живой ответ поставщика прогревает нашу базу: при успехе PriceResolverEngine.cacheWarm отсоединённо пишет наблюдение через ObservationWriter — UpsertLiveSnapshot (цена + остаток + срок) либо UpsertLivePrice. То есть расхождение, увиденное в ходе клиентского запроса, не теряется.
Оговорка, без которой механизм выглядит работающим, хотя не работает: прогрев случается только при УСПЕШНОЙ живой попытке, а она требует учётных данных со scope=customer. На проде 2026-08-18 таких нет ни одной (все шесть записей — system), поэтому живой путь отклоняется с live_customer_scope_mismatch, к поставщику никто не идёт, расхождения не возникает и прогревать нечего.
Подбор тоже поднимает позиции в очередь прогрева (enqueueProposalFocusOffers), как это давно делают сметы и подбор аналогов. Клиент спросил про позиции — значит их свежесть важна сейчас: замер прода 2026-08-18 показал наблюдения возрастом 6 ч (etm) и 15 ч (russvet) по проверяемому товару.
Важно, чем этот путь отличается от живого запроса: очередь прогрева обслуживается СИСТЕМНЫМИ учётными данными, клиентские ей не нужны. Поэтому подъём работает и тогда, когда живой запрос отклонён по live_customer_scope_mismatch — то есть сегодня это единственный способ, которым интерес клиента доходит до актуализации данных.
Устройство: очередь ключуется парой (supplier, supplier_sku), а OfferCandidate в подборе несёт только OfferRef и SupplierRef, поэтому в слое di живёт адаптер offerSKUResolverAdapter (порт OfferSKUResolver объявлен в infra/http — arch-lint запрещает тому импортировать offers BC). Подъём отсоединён от ответа, ограничен proposalFocusEnqueueLimit = 25 позициями на запрос (иначе популярный поиск затопил бы очередь и вытеснил всё остальное) и укладывается в 5 секунд. Метрика on_demand_enqueued_total разводит источники ярлыком trigger: estimate против search — иначе по ней нельзя понять, что именно греет базу.
Снимок правил перечитывается по таймеру
Движок цен читает правила не из базы, а из снимка в памяти (CustomerSnapshotHolder). До 2026-08-17 снимок собирался ровно один раз, в хуке fx.OnStart: активированное клиентом правило не доходило до расчёта до следующего перезапуска api-server, хотя кабинет уже писал «Применяется к расчётам». Дефект тихий — ошибок нет, просто цена не меняется.
CustomerSnapshotLoader.Run пересобирает снимок каждые PRICING_CUSTOMER_SNAPSHOT_RELOAD_INTERVAL (по умолчанию 30 с). Отсюда следует ожидаемая задержка: между активацией правила и изменением цены проходит до одного интервала плюс время построения проекции. Проверять применение правила сразу после активации бессмысленно.
Путь авторизации (важно при отладке 401). Кука кабинета сама по себе backend’у ничего не говорит: platform/auth.SubjectFromTrustedRequest читает только заголовок jwt, а ставит его nginx через includes/id.conf (auth_request → auth-service). Поэтому cookie-ветка диспетчера /v1/search/proposals обязана включать id.conf:
| Ветка | Условие | Что даёт identity |
|---|---|---|
@*_search_bearer | есть Authorization: Bearer | auth_request /_internal/auth/api-key/verify → заголовки X-Tr-* |
@*_search_jwt_cookie | иначе (сессия кабинета) | include id.conf → заголовок jwt |
До 2026-08-17 обе прод-ветки (@prod_search_jwt_cookie, @api_domain_search_jwt_cookie) шли pass-through с комментарием «api-server валидирует cookie самостоятельно». Это было неверно — куку не читает никто, и любой cookie-запрос к подбору получал 401. В локальном дереве (nginx-local/templates/tracium.dev.conf.template) id.conf стоял с самого начала, поэтому расхождение не проявлялось на dev-стенде.
Пользователь ≠ клиент: вторая ступень identity
Пройти авторизацию мало — надо ещё понять, от имени какого клиента действует сессия. JWT этого не несёт: auth-service про сущность «клиент» не знает и кладёт в токен только uid. Хуже того, platform/auth.SubjectFromTrustedJWT записывает одно и то же значение (firstNonEmpty(uid, customer_id), где uid побеждает) в оба поля Subject — и UserID, и CustomerID.
Отсюда правило: Subject.CustomerID у cookie-сессии — это идентификатор пользователя, а не клиента. Сопоставление живёт в BC cabinet, в таблице customer_members (WHERE user_id = $1 AND role = 'owner').
Две ступени разрешения, обе рабочие:
| Путь | Кто резолвит клиента |
|---|---|
/api/cabinet/*, /api/v1/pricing/rules, /api/v1/credentials | customerSubjectDualFlow → cabinet/customer/api/http.SubjectMiddleware(Lookup) — кладёт клиента в контекст кабинета |
/v1/search/proposals, /v1/estimates*, ассистент | apikeyauth.RequireScopeOrLegacyJWTFor + порт CustomerResolver — замещает Subject.CustomerID настоящим клиентом |
Реализация порта — cabinet/customer/api/http.SubjectCustomerResolver (кеш на минуту поверх того же Lookup, чтобы разрешение не добавляло обращение к базе на каждый запрос /v1/*). Ошибка разрешения не рушит запрос: клиент остаётся прежним, факт пишется в auth.customer_resolve_failed — пробел в данных не должен превращаться в отказ обслуживания.
Почему это важно (замер прода 2026-08-17). Публичные пути читали Subject.CustomerID напрямую (search/proposal/infra/http/handler.go строил CustomerContext из него), то есть искали данные клиента по идентификатору пользователя. В логе подбора стоял customer_ref=afc1867d… (пользователь), а ценовые правила принадлежали ec107e7f… (клиент, владелец). Результат: ценовые правила клиента не применялись ни к одному запросу из кабинета — adjustments: [], слой трассы adjustments со статусом ok. Молча, потому что в CustomerRuleAdjustmentApplier «правил не нашлось» и «правила не подошли» — одна и та же ветка. Bearer-путь дефекта не имел: там X-Tr-Customer-Id ставит nginx из записи API-ключа.
Вывод для новых эндпоинтов под /v1/*: если путь берёт клиента из Subject.CustomerID, он обязан получить CustomerResolver. Иначе он тихо работает не с тем арендатором.
Зависимости
- PostgreSQL — все sub-BC.
- nginx —
auth_requestк auth-service (JWT-cookie validation). - auth-service — emit JWT after login.
- outbox — atomic event publish.
Хранилище
| Таблица | Назначение |
|---|---|
customer | Customer entity + version |
customer_owner_member | Owner graph (multi-owner) |
customer_profile | Single-table profile fields |
api_keys | Token storage (HMAC + scopes + role + env) |
customer_credentials | Customer-scope supplier credentials (encrypted secrets) |
outbox_events | Async event publication |
Конфигурация
| Env var | Default | Описание |
|---|---|---|
KEYS_HASH_PEPPER | — | ≥32 bytes, HMAC pepper для API-keys hash. Required. |
JWT_SECRET | — | HS512 secret для auth-service. |
CABINET_PROFILE_AUTOSAVE_DEBOUNCE_MS | 400 | UI debounce для autosave. |
Локальный запуск
make local-prod-upUI на https://tracium.dev:4444/cabinet, API на https://api.tracium.dev:4444/api/cabinet/*.
Тестирование
- Unit + integration:
go test ./internal/core/cabinet/...+-tags=integrationдля PG-зависимых. - Frontend:
cd web/landing && pnpm vitest run— vitest unit tests. - E2E: Playwright —
web/landing/e2e/cabinet-{profile,keys,credentials}.spec.ts. - Live curl-verify: см.
tracium_curl_live_verifymemory + execution memos для каждого sub-BC.
Наблюдаемость
- Metrics:
apikey_auth_attempts_total{result},cabinet_profile_save_total, etc. - Phase logs:
platform/observability.StartPhase. - Outbox events для audit-trail.
Открытые вопросы / TODO
См. tracium_path_to_launch.md и tracium_cabinet_design_handoff.md:
- I4b Keys — usage counter + IP allowlist + expired-tick + cross-env (~70-80 commits). Перепроектировать под ADR-0051 (auth-service / nginx-terminator, не in-process middleware).
- I4c Keys — manual block + tier-limits + Playground (~45-50 commits).
- Stats screen — HonestBars + per-key/per-credential time-series.
- OAuth screen — Google/Yandex/email-link bindings.
- Subscription/Billing — Billing BC + PSP integration.
- Security audit log — events_log read-side из outbox.
- Overview KPI — сейчас минимальный screen с email/userid.
- Credentials health columns + test-connection — backend customer-DTO extension + UI.
Связанные документы
- ADR:
0051,0041. - Public API:
../public-api/README.md. - Ingestion (потребитель customer credentials):
../ingestion/README.md. - Design handoff:
docs/handoff/2026-05-04/cabinet/.