From 72173565944ffc649dfbcb4d3872c06f4f9d2ce7 Mon Sep 17 00:00:00 2001 From: IK Date: Mon, 7 Sep 2026 13:00:58 +0700 Subject: [PATCH] feat: enable safe community auto publishing --- README.md | 6 +- .../0012_enable_authorized_sources.py | 23 ++++++++ apps/api/app/community_importer.py | 41 ++++++++++++- apps/api/app/models.py | 2 +- apps/api/tests/test_community_importer.py | 58 +++++++++++++++++-- deploy/test-production-bootstrap.sh | 2 +- docs/ROADMAP.md | 1 + docs/community-source-pilot.md | 10 ++-- docs/data-permissions.md | 6 +- docs/data-policy.md | 2 +- 10 files changed, 130 insertions(+), 21 deletions(-) create mode 100644 apps/api/alembic/versions/0012_enable_authorized_sources.py diff --git a/README.md b/README.md index dfa0943..8fc6051 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ RF4 Spotter — неофициальный сервис свежих точек Функциональный MVP и локальный production-контур готовятся к открытой альфе: официальный импорт, пользовательские заявки, модерация, объяснимый индекс, staging внешних источников, адаптивный Astro UI, миграции, резервное копирование, retention, мониторинг и security/accessibility-проверки реализованы. Автоматические импорты внешних источников выключены. Публичный запуск блокируют покупка и настройка сервера, DNS/TLS, реальные секреты, внешний backup, канал уведомлений и публичные страницы правил/privacy. -RF4DB/RF4-STAT/RF4MAP/RF4 Posts принимаются только в изолированный staging и не влияют на индекс без ручного сопоставления и публикации. Для разрешённых community-источников действует интервал не менее 30 минут на источник. Открытая альфа не использует продуктовый allowlist: интерфейс показывает весь корректно загруженный разрешённый каталог, сохраняя требования полноты и модерации. +RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изолированный staging. Полные записи с ранее подтверждёнными алиасами источника публикуются автоматически; новые соответствия и неполные записи остаются на ручной проверке. Для разрешённых community-источников действует интервал не менее 30 минут на источник. Открытая альфа не использует продуктовый allowlist: интерфейс показывает весь корректно загруженный разрешённый каталог, сохраняя требования полноты и модерации. Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md). @@ -112,7 +112,7 @@ docker compose up --build ## Что реализовано - FastAPI и SQLAlchemy 2; -- PostgreSQL 17 и миграции Alembic до `0011`; +- PostgreSQL 17 и миграции Alembic до `0012`; - идемпотентный seed с двумя точками и свежими демо-уловами; - `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки; - `GET /api/v1/spots/{id}` и `/catches`; @@ -213,7 +213,7 @@ Production-логи структурированы в JSON и не содерж ## Известные ограничения альфы - нет пользовательских аккаунтов, OCR, Telegram-бота и уведомлений о клёве; -- community-источники требуют ручной проверки; неполные наблюдения не публикуются; +- community-источники автоматически публикуют только полные наблюдения с подтверждёнными алиасами; новые соответствия требуют ручной проверки, неполные наблюдения не публикуются; - offset pagination рассчитана на пилотные объёмы, не на бесконечную ленту; - Lighthouse LCP локального прогона — 9,3 с; оптимизация изображения/CDN остаётся после размещения; - один сервер остаётся точкой отказа, поэтому обязательны внешний backup и мониторинг; diff --git a/apps/api/alembic/versions/0012_enable_authorized_sources.py b/apps/api/alembic/versions/0012_enable_authorized_sources.py new file mode 100644 index 0000000..46ab744 --- /dev/null +++ b/apps/api/alembic/versions/0012_enable_authorized_sources.py @@ -0,0 +1,23 @@ +"""Enable all authorized community sources in the staging registry.""" +from alembic import op + +revision = "0012" +down_revision = "0011" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute(""" + INSERT INTO data_source (key, name, base_url, default_confidence, enabled) VALUES + ('rf4db', 'RF4DB', 'https://rf4db.com', 70, true), + ('rf4stat-fishing', 'RF4-STAT fishing', 'https://rf4-stat.ru/fishing/', 65, true), + ('rf4stat-post', 'RF4-STAT posts', 'https://rf4-stat.ru/posts/', 60, true), + ('rf4map', 'RF4MAP', 'https://rf4map.ru', 55, true), + ('rf4posts-spot', 'RF4 Posts spots', 'https://rf4-posts.com', 50, true) + ON CONFLICT (key) DO UPDATE SET enabled = EXCLUDED.enabled + """) + + +def downgrade() -> None: + op.execute("UPDATE data_source SET enabled = false WHERE key IN ('rf4db', 'rf4stat-fishing', 'rf4stat-post', 'rf4map', 'rf4posts-spot')") diff --git a/apps/api/app/community_importer.py b/apps/api/app/community_importer.py index 84aefbb..17725bf 100644 --- a/apps/api/app/community_importer.py +++ b/apps/api/app/community_importer.py @@ -8,7 +8,8 @@ from urllib.parse import urlparse from sqlalchemy import select from sqlalchemy.orm import Session -from .models import DataSource, ExternalObservation +from .community_review import publish_observation +from .models import DataSource, ExternalEntityAlias, ExternalObservation SOURCE_DEFAULTS = { @@ -36,6 +37,7 @@ def stage_observations( ) -> tuple[int, int]: fetched_at = fetched_at or datetime.now(timezone.utc) created = updated = 0 + touched: list[ExternalObservation] = [] for raw in records: payload = _json_payload(raw) source_system = _required(payload, "source_system", 50) @@ -45,7 +47,7 @@ def stage_observations( source = session.get(DataSource, source_system) if source is None: name, base_url, confidence = SOURCE_DEFAULTS[source_system] - source = DataSource(key=source_system, name=name, base_url=base_url, default_confidence=confidence, enabled=False) + source = DataSource(key=source_system, name=name, base_url=base_url, default_confidence=confidence, enabled=True) session.add(source) session.flush() observation = session.scalar(select(ExternalObservation).where( @@ -78,10 +80,45 @@ def stage_observations( for key, value in values.items(): setattr(observation, key, value) updated += 1 + touched.append(observation) session.commit() + for observation in touched: + _auto_publish(session, observation) return created, updated +def _auto_publish(session: Session, observation: ExternalObservation) -> bool: + """Publish only complete observations covered by previously reviewed aliases.""" + if ( + observation.status not in {"staged", "mapped", "ready"} + or not observation.source.enabled + or observation.fish_external_id is None + or observation.waterbody_external_id is None + or observation.x is None + or observation.y is None + or observation.weight_g is None + ): + return False + fish_alias = session.scalar(select(ExternalEntityAlias).where( + ExternalEntityAlias.source_system == observation.source_system, + ExternalEntityAlias.entity_type == "fish", + ExternalEntityAlias.external_id == observation.fish_external_id, + )) + waterbody_alias = session.scalar(select(ExternalEntityAlias).where( + ExternalEntityAlias.source_system == observation.source_system, + ExternalEntityAlias.entity_type == "waterbody", + ExternalEntityAlias.external_id == observation.waterbody_external_id, + )) + if fish_alias is None or fish_alias.fish is None or waterbody_alias is None or waterbody_alias.waterbody is None: + return False + observation.fish = fish_alias.fish + observation.waterbody = waterbody_alias.waterbody + observation.status = "ready" + observation.review_note = "Automatically matched by previously reviewed source aliases" + publish_observation(session, observation) + return True + + def _json_payload(raw: dict[str, Any]) -> dict[str, Any]: if not isinstance(raw, dict): raise CommunityImportError("each observation must be an object") diff --git a/apps/api/app/models.py b/apps/api/app/models.py index e54b155..6a1f864 100644 --- a/apps/api/app/models.py +++ b/apps/api/app/models.py @@ -145,7 +145,7 @@ class DataSource(Base): name: Mapped[str] = mapped_column(String(100)) base_url: Mapped[str] = mapped_column(Text) default_confidence: Mapped[int] - enabled: Mapped[bool] = mapped_column(default=False) + enabled: Mapped[bool] = mapped_column(default=True) class ExternalObservation(Base): diff --git a/apps/api/tests/test_community_importer.py b/apps/api/tests/test_community_importer.py index cf90f92..92e8d37 100644 --- a/apps/api/tests/test_community_importer.py +++ b/apps/api/tests/test_community_importer.py @@ -9,7 +9,7 @@ from sqlalchemy.orm import Session from app.community_importer import CommunityImportError, stage_observations from app.database import Base -from app.models import DataSource, ExternalObservation +from app.models import CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, Waterbody from rf4_research.community_sources import parse_rf4db_catches, parse_rf4map_point, parse_rf4posts_spot @@ -64,7 +64,7 @@ def test_staging_is_idempotent_and_preserves_first_seen(db: Session) -> None: assert item.last_seen_at.replace(tzinfo=timezone.utc) == second assert db.scalar(select(func.count()).select_from(ExternalObservation)) == 1 source = db.get(DataSource, "rf4db") - assert source is not None and source.enabled is False + assert source is not None and source.enabled is True def test_external_ids_are_isolated_by_source(db: Session) -> None: @@ -73,12 +73,60 @@ def test_external_ids_are_isolated_by_source(db: Session) -> None: assert (created, updated) == (2, 0) -def test_research_sources_can_enter_disabled_staging(db: Session) -> None: +def test_research_sources_enter_enabled_staging(db: Session) -> None: created, updated = stage_observations(db, [record("rf4map"), record("rf4posts-spot")]) assert (created, updated) == (2, 0) - assert db.get(DataSource, "rf4map").enabled is False - assert db.get(DataSource, "rf4posts-spot").enabled is False + assert db.get(DataSource, "rf4map").enabled is True + assert db.get(DataSource, "rf4posts-spot").enabled is True + + +def test_complete_observation_with_reviewed_aliases_is_published(db: Session) -> None: + source = DataSource(key="rf4db", name="RF4DB", base_url="https://rf4db.com", default_confidence=70, enabled=True) + fish = Fish(slug="pike", name_ru="Щука") + waterbody = Waterbody(slug="test-lake", name_ru="Тестовое озеро") + db.add_all([source, fish, waterbody]) + db.flush() + now = datetime.now(timezone.utc) + db.add_all([ + ExternalEntityAlias(source_system="rf4db", entity_type="fish", external_id="pike", external_name="Щука", fish=fish, updated_at=now), + ExternalEntityAlias(source_system="rf4db", entity_type="waterbody", external_id="test-lake", external_name="Тестовое озеро", waterbody=waterbody, updated_at=now), + ]) + db.commit() + + assert stage_observations(db, [record() | {"weight_g": 5_000}]) == (1, 0) + + item = db.scalar(select(ExternalObservation)) + assert item is not None + assert item.status == "published" + assert item.catch_report is not None + assert item.catch_report.fish_id == fish.id + assert item.catch_report.waterbody_id == waterbody.id + assert db.scalar(select(func.count()).select_from(CatchReport)) == 1 + + assert stage_observations(db, [record() | {"weight_g": 5_000}]) == (0, 1) + db.refresh(item) + assert item.status == "published" + assert db.scalar(select(func.count()).select_from(CatchReport)) == 1 + + +def test_auto_publication_requires_enabled_source(db: Session) -> None: + source = DataSource(key="rf4db", name="RF4DB", base_url="https://rf4db.com", default_confidence=70, enabled=False) + fish = Fish(slug="pike", name_ru="Щука") + waterbody = Waterbody(slug="test-lake", name_ru="Тестовое озеро") + db.add_all([source, fish, waterbody]) + db.flush() + now = datetime.now(timezone.utc) + db.add_all([ + ExternalEntityAlias(source_system="rf4db", entity_type="fish", external_id="pike", external_name="Щука", fish=fish, updated_at=now), + ExternalEntityAlias(source_system="rf4db", entity_type="waterbody", external_id="test-lake", external_name="Тестовое озеро", waterbody=waterbody, updated_at=now), + ]) + db.commit() + + stage_observations(db, [record() | {"weight_g": 5_000}]) + + item = db.scalar(select(ExternalObservation)) + assert item is not None and item.status == "staged" and item.catch_report is None def test_parser_json_can_be_staged_without_losing_provenance(db: Session) -> None: diff --git a/deploy/test-production-bootstrap.sh b/deploy/test-production-bootstrap.sh index 15cd993..3094384 100755 --- a/deploy/test-production-bootstrap.sh +++ b/deploy/test-production-bootstrap.sh @@ -26,7 +26,7 @@ curl -fsS "http://127.0.0.1:$BOOTSTRAP_API_PORT/ready" >/dev/null curl -fsS "http://127.0.0.1:$BOOTSTRAP_WEB_PORT/" >/dev/null curl -fsS -D - -o /dev/null "http://127.0.0.1:$BOOTSTRAP_API_PORT/health" | grep -qi '^x-frame-options: DENY' curl -fsS -D - -o /dev/null "http://127.0.0.1:$BOOTSTRAP_API_PORT/health" | grep -qi '^cross-origin-opener-policy: same-origin' -test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select version_num from alembic_version')" = "0011" +test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select version_num from alembic_version')" = "0012" index_count=$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c "select count(*) from pg_indexes where schemaname = 'public' and indexname in ('ix_catch_report_activity_lookup','ix_catch_report_spot_feed','ix_catch_report_moderation_queue','ix_catch_report_official_records','ix_official_import_source_status_started','ix_external_observation_review_queue','ix_submission_attempt_client_created','ix_moderation_event_created_at')") test "$index_count" = "8" test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from fish')" = "2" diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 76190c8..730ec56 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -65,6 +65,7 @@ - [x] Сделать seed устойчивым к частично заполненной БД: справочники досеиваются независимо, демо-уловы идемпотентны и принудительно отключены в production; повторный/частичный запуск покрыт конфигурационными и интеграционными проверками. - [x] Проверить списочные API по требованию раздела 12: все выдачи имеют ограниченные `limit`/`offset`, детерминированный tie-breaker и типизированные фильтры; фильтр категории рекордов перенесён до пагинации. - [x] Добавить составные индексы PostgreSQL для activity, модерации, официальных рекордов, staging, импорта, аудита и очистки rate limit (миграция `0011`); бюджет и процедура проверки планов зафиксированы в `docs/query-performance.md`. +- [x] Включить все пять разрешённых community-источников и автоматическую публикацию полных наблюдений по ранее подтверждённым алиасам без межисточникового склеивания (миграция `0012`, 7 сентября 2026). - [x] Провести security-проверку admin-аутентификации, CORS, headers, загрузок, контейнерных пользователей и секретов: двойная защита admin web/API, constant-time token, no-store, non-root API/web и отдельные MinIO root/app credentials; остаточные ограничения записаны в `docs/security-review.md`. - [x] Проверить авторизацию повторной загрузки скриншота: используется отдельный одноразовый случайный токен, в БД хранится только SHA-256, UUID заявки недостаточно. - [x] Определить сроки хранения ников, исходных payload, staging-наблюдений, moderation events и submission attempts; добавлены настраиваемая dry-run-first очистка, тест и `docs/data-retention.md`. diff --git a/docs/community-source-pilot.md b/docs/community-source-pilot.md index 0b2d715..6329ed4 100644 --- a/docs/community-source-pilot.md +++ b/docs/community-source-pilot.md @@ -64,14 +64,14 @@ Detail-страница дополнительно содержит ветер, - тесты всех извлекаемых полей, locked-координат и отказа на постороннем HTML; - живой контрольный прогон без сохранения персональных данных и изображений в репозиторий. -RF4MAP и RF4 Posts остаются исследовательскими: на живых HTML-снимках получены соответственно 30 индивидуальных наблюдений и 6 видов рыб одной точки. Оба результата укладываются в nullable-контракт, имеют отдельные пространства ID и не смешиваются с RF4DB/RF4-STAT. Разрешение и минимальный интервал 30 минут подтверждены; источники разрешены для staging, но остаются выключенными. RF4 Posts трактуется как агрегированная точка, а не индивидуальный улов. +На живых HTML-снимках RF4MAP и RF4 Posts получены соответственно 30 индивидуальных наблюдений и 6 видов рыб одной точки. Оба результата укладываются в nullable-контракт, имеют отдельные пространства ID и не смешиваются с RF4DB/RF4-STAT. Разрешение и минимальный интервал 30 минут подтверждены; все источники включены миграцией `0012`. RF4 Posts трактуется как агрегированная точка, а не индивидуальный улов. ## Перед продуктивным импортом 1. Сохранить подтверждение разрешения и согласованные лимиты запросов. 2. Расширять добавленные алиасы рыб и водоёмов по мере ручной проверки новых значений. -3. Согласовать начальные уровни доверия для RF4DB и двух каналов RF4-STAT (в staging записаны консервативные значения 70/65/60, но источники выключены). -4. Проверить правила публикации на небольшой вручную подтверждённой выборке. +3. При необходимости пересмотреть консервативные начальные уровни доверия 70/65/60/55/50. +4. Расширять контрольную выборку автопубликации по мере появления полных наблюдений. 5. Не хранить и не проксировать изображения без отдельного условия разрешения; на первом этапе достаточно исходной ссылки. ## Staging @@ -80,6 +80,6 @@ RF4MAP и RF4 Posts остаются исследовательскими: на Загрузка принимает только известные источники, HTTPS-ссылки соответствующего домена и значения в допустимых диапазонах. Все записи получают статус `staged`; таблица не связана с расчётом активности. Тест доказывает путь `HTML → ExternalCatch → JSON → external_observation` без потери provenance. -Контрольная загрузка в PostgreSQL создала 139 staging-записей: 23 `rf4db` с координатами, 100 `rf4stat-fishing` с весом и 16 `rf4stat-post` с весом. Немедленный повтор дал `created=0, updated=139`, подтвердив идемпотентность на реальной БД. Все три источника остались `enabled=false`. +Контрольная загрузка в PostgreSQL создала 139 staging-записей: 23 `rf4db` с координатами, 100 `rf4stat-fishing` с весом и 16 `rf4stat-post` с весом. Немедленный повтор дал `created=0, updated=139`, подтвердив идемпотентность на реальной БД. Миграция `0012` включает все пять разрешённых источников. -Миграция `0009` добавляет устойчивые алиасы внешних рыб и водоёмов, канонические ссылки и состояние ручной проверки. Страница `/admin/external-sources` позволяет сопоставить, отклонить или явно опубликовать наблюдение. Публикация создаёт одобренный `catch_report` с исходной ссылкой и полным provenance, но только если одновременно известны канонические сущности, координаты и вес. Поэтому текущие 139 неполных записей после миграции остались в staging. +Миграция `0009` добавляет устойчивые алиасы внешних рыб и водоёмов, канонические ссылки и состояние ручной проверки. Страница `/admin/external-sources` позволяет сопоставить, отклонить или явно опубликовать наблюдение. После миграции `0012` повторный импорт автоматически публикует запись только при одновременном наличии подтверждённых алиасов конкретного источника, координат и веса; остальные записи остаются в staging. Поэтому текущие 139 неполных записей не опубликованы. diff --git a/docs/data-permissions.md b/docs/data-permissions.md index 8549936..e4d2a50 100644 --- a/docs/data-permissions.md +++ b/docs/data-permissions.md @@ -10,7 +10,7 @@ - Изображения: не скачиваются, не проксируются и не архивируются без отдельного явно зафиксированного условия. - Атрибуция: опубликованный улов обязан сохранять `source_system`, внешний ID и прямой `source_url` в provenance. - Объединение источников: автоматическое склеивание RF4DB и RF4-STAT запрещено, пока нет надёжного общего идентификатора. -- Публикация: только вручную, после канонического сопоставления; обязательны рыба, водоём, координаты и вес. +- Публикация: автоматически по ранее вручную подтверждённым алиасам источника либо вручную; обязательны рыба, водоём, координаты и вес. ## RF4MAP и RF4 Posts @@ -19,7 +19,7 @@ - Частота: не чаще одного получения страницы каждого источника раз в 30 минут. - Разрешённый контур: публичные HTML-страницы, нормализация в `ExternalCatch` и закрытый staging; запрещённые в `robots.txt` API не запрашиваются. - Изображения: сохраняются только исходные URL доказательств; файлы не скачиваются и не проксируются. -- Публикация: только вручную и только при выполнении общего требования рыба + водоём + координаты + вес. Текущие записи RF4MAP/RF4 Posts не имеют веса и остаются в staging. +- Публикация: автоматически по подтверждённым алиасам либо вручную и только при выполнении общего требования рыба + водоём + координаты + вес. Текущие записи RF4MAP/RF4 Posts не имеют веса и остаются в staging. - Семантика RF4 Posts: несколько видов одного поста остаются связанными с одним UUID точки и не считаются отдельными подтверждёнными взвешиваниями. ## Лимиты запросов @@ -30,7 +30,7 @@ - RF4DB: один запрос списка за ручной запуск; detail-страницы запрашиваются только адресно, без массового обхода. - RF4MAP: не чаще одного запроса раз в 30 минут. - RF4 Posts: не чаще одного запроса раз в 30 минут. -- регулярные scheduler-задачи для обоих источников выключены; +- все пять разрешённых community-адаптеров включены в реестре staging; регулярные scheduler-задачи остаются выключены; - при `429`, `403`, изменении DOM или неожиданном типе ответа сбор прекращается без попытки обхода ограничения. ## Что ещё требуется зафиксировать diff --git a/docs/data-policy.md b/docs/data-policy.md index c64e377..51a8cba 100644 --- a/docs/data-policy.md +++ b/docs/data-policy.md @@ -14,7 +14,7 @@ RF4 Spotter обрабатывает официальные публичные - IP не хранится: rate limit использует HMAC-отпечаток с секретом сервера; - из изображений удаляются EXIF и прочие метаданные; - query string, authorization headers и пользовательские payload не попадают в HTTP-лог; -- community-наблюдения сначала поступают в изолированный staging и публикуются только после ручной проверки. +- community-наблюдения сначала поступают в изолированный staging; полные записи автоматически публикуются только по ранее вручную подтверждённым алиасам источника, остальные требуют ручной проверки. ## Источники и атрибуция