[C28-21] Реестр подписанных документов в Postgres: getDocuments+searchDocuments на PG, newdeclined, авто-backfill #47
Reference in New Issue
Block a user
Delete Branch "feat/C28-21-pg-signed-documents"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
C28-21 — Реестр подписанных документов в Postgres
Документы кооператива теперь хранятся, отображаются и ищутся из единого Postgres-реестра, наполняемого ловлей blockchain-событий контракта
sovietи разовым backfill'ом. Одним PR убираются: тяжёлый on-the-fly-сбор агрегатов (explorer/Mongo на каждый запрос) и OpenSearch (отдельный JVM-сервис) — поиск и список документов идут через один источник.Что работает по-новому
SignedDocumentEntity(таблицаsigned_documents, одна строка = один документ, уникальный ключ(coopname, doc_hash)). Тонкость идентичности:doc_hash— сам документ, аhashвключает наложенные подписи, поэтому у одного документа бывает несколькоhash(версии по мере накопления подписей — напр. акт приёма-передачи: первая/вторая подпись дают разныеhashпри одномdoc_hash). В реестре держим крайнюю версию (поblock_num), иначе документ задвоился бы в списке;hashхранится как версия последней подписи (для фильтра/отображения).package— идентификатор ПРОЦЕССА и не уникален: один процесс содержит несколько РАЗНЫХ документов с разными подписантами (напр. обмен в marketplace — дваnewsubmittedс однимpackage); группирующая колонка, по ней в агрегат тянутся решение/акты/связи. Вdocument_aggregate(jsonb) лежит готовый агрегат (statement/decision/acts/links вместе с контентом и PDF-бинарём base64) — payload дляgetDocuments. Рядом денормализованная проекция (full_title,content_text,signers_text,action,registry_id,username,hash,block_num,document_created_at,status).getDocuments— read-path из PG: отдаёт готовый агрегат без сборки на лету и без обращений к explorer/Mongo. Контракт DTO (DocumentPackageAggregate) сохранён 1:1, агрегат собирается тем жеDocumentPackageAggregator, что и раньше (теперь на этапе ingestion/backfill).getDocumentsстарому пути. ПрежнийDocumentServiceподмешивал в explorer-фильтрreceiver: username(on-chainrequire_recipient):receiver=coopname→ все документы кооператива,receiver=<пайщик>→ его документы; Union-страница добавлялаfilter.document.hash. PG read-path теперь воспроизводит это точно:receiver===coopname→ весь кооператив, иначе фильтрusername=receiver(колонка = пайщик-субъект заявления), плюс регистронезависимый фильтр поhash. Проверено для всех трёх потребителей (Cooperative/ListOfDocuments,User/DocumentsPage,Union/ListOfCooperatives).type) — по имени действия, не партиция по статусу. Прежде фильтровалось по имени soviet-действия: «Все входящие» (newsubmitted) = все документы (у каждого пакета есть submitted-действие, решённые тоже входят), «Только утверждённые» (newresolved) = решённые. PG read-path:newsubmitted→ без фильтра статуса,newresolved→status=Resolved. Партиция по статусу скрыла бы решённые из вкладки «Все входящие» — исправлено.getDocumentsподRolesGuard: обычному пайщику доступен толькоdata.username==его_имя→ фильтр сводит выборку к его документам; coop-wide (receiver=coopname) — лишь совету/председателю.findAggregatesвсегда фильтрует поconfig.coopname. Утечка чужих/всех документов пайщику структурно невозможна.searchDocuments— честный SQLILIKEпо PG-реестру:signers_text(ФИО подписантов, приоритетно),full_title,content_text,username. КонтрактSearchResultDTOсохранён. Скоуп по роли: член совета (chairman/member) ищет по всему кооперативу, обычный пайщик — только по своим документам (username=user.username). Так пайщик пользуется поиском на своей странице, но не видит чужих документов (заодно закрыта прежняя утечка — старый поиск был coop-wide для любого авторизованного).signers_textсобирает ФИО/наименования всех подписантов пакета (last/first/middle_name,short_nameизsigner_certificateвсех частей агрегата — заявление/решение/акты/связанные) + их username. Это покрывает соподписантов (председатель/совет), которых нет в теле заявления (content_text).SignedDocumentIngestionService) — подписка на внутреннюю шину@OnEvent(action::soviet::*), сборка готового агрегата черезDocumentPackageAggregator:newsubmitted/newresolved/newdeclined→ пересобрать агрегат + выставить статус (с защитой от понижения);newdecision/newact/newlink→ досборка агрегата по сохранённому действию-носителю (source_action_data), статус не меняем.SignedDocumentBackfillService) — разовая идемпотентная миграция: скан действийnewsubmitted/newresolved/newdeclinedсо скоупом{receiver: coopname}(ровно как прежний chairman-путьgetDocuments— один трейс на документ, только наш кооператив) → сборка агрегатов → PG. Запускается автоматически один раз при пустом реестре (onApplicationBootstrap, детачем, не блокирует старт) либо принудительно поSIGNED_DOCS_BACKFILL_ON_BOOT=true.Удаление OpenSearch (выполнено)
infrastructure/search/*(opensearch.service,search-registry.service,search-infrastructure.module) иapplication/search/services/search-event.service(старый индексатор поnewsubmitted— заменён ingestion'ом + backfill'ом).app.module: снятSearchInfrastructureModule.search.module: толькоSearchResolver(читаетSIGNED_DOCUMENT_REPOSITORYиз глобальногоTypeOrmModule).package.json: убрана@opensearch-project/opensearch;pnpm-lock.yamlпересчитан.docker-compose: удалён сервисopensearch+ volumeopensearch_data..env-example: убраныOPENSEARCH_*.system.interactor:features.search = true(поиск на PG доступен всегда, не зависит отOPENSEARCH_ENABLED).cooptypes
Заведено registry-действие soviet
newdeclined(contracts/soviet/actions/registry/newdeclined.ts+ экспорт вRegistry); статусdeclinedтеперь отслеживается ingestion'ом.distпакета gitignored — пересобирается в CI (локально пересобран для проверки типов).Зафиксированные решения (C28-21)
resolved(основной) /declined(аннулирование) /submitted(почти не используется).receiver-скоуп старого пути воспроизводится через колонкуusername(= пайщик-субъект заявления): для soviet-заявлений субъект совпадает с on-chain получателем уведомления.Осознанные ограничения / следующий шаг
getDocumentsточна после backfill'а: на чистом реестре авто-backfill наполняет таблицу один раз при старте; до его завершения список частичный. Если реестр уже наполнялся прежним деплоем без колонокsigners_text/новых полей — разовоSIGNED_DOCS_BACKFILL_ON_BOOT=true.Размер: агрегат с PDF base64 для каждой части пакета может быть весомым; PG TOAST'ит/сжимает. При необходимости — оптимизация (вынести бинарь) отдельно.
Связано: C28-16 (зонтик composite-entity), C28-13 (parser2), C28-15 (event-driven), C28-18 (автосверка).
Фундамент C28-21: PG-проекция подписанных документов (таблица signed_documents), наполняемая ловлей blockchain-событий soviet с внутренней шины и разовым backfill'ом. Аддитивно — OpenSearch и резолверы (searchDocuments/getDocuments) пока не трогаются. - SignedDocumentEntity (signed_documents): package/hash/coopname/username/status, full_title/content_text/html/pdf(bytea), document_aggregate(jsonb), meta, block_num. - SignedDocumentRepository (+SIGNED_DOCUMENT_REPOSITORY) + TypeORM-реализация (upsert/setStatus/search ILIKE). - SignedDocumentIngestionService: @OnEvent(action::soviet::{newsubmitted,newresolved,newdeclined}) -> идемпотентный upsert с защитой от понижения статуса; контент из factory-Mongo. - SignedDocumentBackfillService: разовый идемпотентный скан действий (env-gate SIGNED_DOCS_BACKFILL_ON_BOOT). - Регистрация в TypeOrmModule + app.module. newdeclined пока не экспортирован как registry-action в cooptypes — подписка по литералу на будущее. Следующая итерация: перевод searchDocuments/getDocuments на PG + снос OpenSearch + тесты. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>[C28-21] Реестр подписанных документов в Postgres — ingestion + backfill (фундамент)to [C28-21] Реестр подписанных документов в Postgres: getDocuments+searchDocuments на PG, newdeclined, авто-backfillСамоаудит видимости документов выявил две регрессии относительно прежнего explorer-пути: 1. Вкладка «Все входящие» (type=newsubmitted) теряла решённые документы. Прежний фильтр был по ИМЕНИ действия: у каждого пакета есть newsubmitted-действие → «Все входящие» = все документы (решённые показывались и тут, и во «Только утверждённые»). Партиция по статусу (newsubmitted→ status=Submitted) скрывала решённые из дефолтной вкладки User/DocumentsPage. Фикс: newsubmitted → без фильтра статуса (все), newresolved → status=Resolved. 2. Backfill сканировал explorer с query={} — без скоупа. Explorer (cooparser) хранит трейсы по получателям; без receiver вернулись бы все трейсы каждого действия (soviet/пайщик) и, возможно, чужие кооперативы. Фикс: query={receiver: coopname} — ровно как прежний chairman-путь getDocuments (один трейс на документ, только наш кооператив). Утечки не было и нет: RolesGuard разрешает обычному пайщику только data.username==его_имя, а фильтр findAggregates ограничивает username; coopname-скоуп (весь кооператив) доступен лишь совету/председателю. findAggregates всегда фильтрует по config.coopname. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>Два уточнения по замечаниям: 1. Ключ реестра переведён с hash на doc_hash. doc_hash = идентичность документа; hash включает наложенные подписи, поэтому у ОДНОГО документа бывает несколько hash (версии по мере накопления подписей — напр. акт приёма-передачи: 1-я подпись → один hash, 2-я → другой, doc_hash тот же). При ключе по hash документ задваивался в списке (одна подпись / две подписи). Теперь: - уникальный индекс (coopname, doc_hash); hash и package — неуникальные индексы; - ingestAction ключуется по data.document.doc_hash и держит КРАЙНЮЮ версию: guard'ы «не понижать статус» и «не откатывать на меньший block_num» (getState отдаёт status+blockNum); крайняя версия перетирает предыдущую (тот же документ, больше подписей); - hash хранится как версия последней подписи (для фильтра Union по document.hash и отображения). package по-прежнему группирует разные документы процесса (обмен = разные doc_hash = разные строки). 2. searchDocuments вернул доступ пайщику, но со скоупом: член совета (chairman/member) ищет по всему кооперативу, обычный пайщик — ТОЛЬКО по своим документам (search scope username=user.username). Снял role-гард, добавил @CurrentUser; SearchResolver решает скоуп по роли. Заодно закрывает прежнюю утечку (старый поиск был coop-wide для любого авторизованного). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>@@ -0,0 +36,4 @@** Сканирует все действия soviet (newsubmitted/newresolved/newdeclined) через explorer-источник,* для каждого достаёт документ из factory-Mongo и складывает в Postgres-проекцию.* Идемпотентен по (coopname, package) — повторный прогон безопасен.точно так?
Поправил: backfill идемпотентен по (coopname, doc_hash), не (coopname, package). package сознательно не уникален (один процесс = несколько разных документов), поэтому ключом записи быть не может — это и была неточность. Коммит
43d9363800.TypeORM @Index(['...']) принимает имена СВОЙСТВ сущности, а не db-колонок. Колонка объявлена @Column({ name: 'package' }) packageHash, поэтому индекс ['coopname','package'] падал на старте: "Index contains column that is missing in the entity (SignedDocumentEntity): package". Индекс по db-колонке `package` строится корректно и через имя свойства packageHash. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>