From c4d1c8b87b5e103f60271f523466c680fcaba92f Mon Sep 17 00:00:00 2001 From: IK Date: Mon, 7 Sep 2026 09:37:01 +0700 Subject: [PATCH] feat: audit full alpha catalog --- README.md | 2 +- apps/api/app/catalog_audit.py | 17 +++++++++++++++++ apps/api/app/cli.py | 8 +++++++- apps/api/tests/test_catalog_audit.py | 26 ++++++++++++++++++++++++++ docs/ROADMAP.md | 4 ++-- docs/open-alpha-acceptance.md | 10 ++++++++-- 6 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 apps/api/app/catalog_audit.py create mode 100644 apps/api/tests/test_catalog_audit.py diff --git a/README.md b/README.md index eaeb2fa..dfa0943 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 минут на источник. +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). diff --git a/apps/api/app/catalog_audit.py b/apps/api/app/catalog_audit.py new file mode 100644 index 0000000..52d882f --- /dev/null +++ b/apps/api/app/catalog_audit.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session + +from .models import CatchReport, ExternalObservation, Fish, SourceType, Spot, Waterbody + + +def audit_catalog(db: Session) -> dict[str, int]: + count = lambda model: db.scalar(select(func.count()).select_from(model)) or 0 + failures = { + "invalid_weights": db.scalar(select(func.count()).select_from(CatchReport).where(or_(CatchReport.weight_g <= 0, CatchReport.weight_g > 3_000_000))) or 0, + "invalid_coordinates": db.scalar(select(func.count()).select_from(Spot).where(or_(Spot.x < -10_000, Spot.x > 10_000, Spot.y < -10_000, Spot.y > 10_000))) or 0, + "incomplete_official_records": db.scalar(select(func.count()).select_from(CatchReport).where(CatchReport.source_type == SourceType.official_record, or_(CatchReport.caught_at.is_(None), CatchReport.source_url.is_(None)))) or 0, + "incomplete_published_staging": db.scalar(select(func.count()).select_from(ExternalObservation).where(ExternalObservation.status == "published", or_(ExternalObservation.fish_id.is_(None), ExternalObservation.waterbody_id.is_(None), ExternalObservation.x.is_(None), ExternalObservation.y.is_(None), ExternalObservation.weight_g.is_(None), ExternalObservation.catch_report_id.is_(None)))) or 0, + } + return {"fishes": count(Fish), "waterbodies": count(Waterbody), "reports": count(CatchReport), "staging": count(ExternalObservation), **failures, "failures": sum(failures.values())} diff --git a/apps/api/app/cli.py b/apps/api/app/cli.py index 526ed59..f24138c 100644 --- a/apps/api/app/cli.py +++ b/apps/api/app/cli.py @@ -11,6 +11,7 @@ from .importer import import_records from .community_importer import stage_observations from .retention import RetentionPolicy, apply_retention from .storage import delete_screenshot +from .catalog_audit import audit_catalog def main() -> int: @@ -25,6 +26,7 @@ def main() -> int: community.add_argument("--limit", type=int, default=500) cleanup = sub.add_parser("cleanup-retention") cleanup.add_argument("--apply", action="store_true", help="apply changes; default is dry-run") + sub.add_parser("audit-catalog") args = parser.parse_args() with SessionLocal() as session: if args.command == "import-records": @@ -43,7 +45,7 @@ def main() -> int: parser.error("input must be a JSON array") created, updated = stage_observations(session, payload[:args.limit]) print(f"staged: created={created} updated={updated}") - else: + elif args.command == "cleanup-retention": policy = RetentionPolicy( submission_days=settings.retention_submission_days, unreviewed_days=settings.retention_unreviewed_days, @@ -54,6 +56,10 @@ def main() -> int: ) counts = apply_retention(session, policy=policy, dry_run=not args.apply, delete_object=delete_screenshot) print(json.dumps({"mode": "apply" if args.apply else "dry-run", "policy": asdict(policy), "counts": counts}, ensure_ascii=False)) + else: + result = audit_catalog(session) + print(json.dumps(result, ensure_ascii=False)) + return 1 if result["failures"] else 0 return 0 diff --git a/apps/api/tests/test_catalog_audit.py b/apps/api/tests/test_catalog_audit.py new file mode 100644 index 0000000..380e963 --- /dev/null +++ b/apps/api/tests/test_catalog_audit.py @@ -0,0 +1,26 @@ +from datetime import datetime, timezone + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from app.catalog_audit import audit_catalog +from app.database import Base +from app.models import CatchReport, Fish, ModerationStatus, SourceType, Spot, Waterbody + + +def test_catalog_audit_checks_the_whole_catalog() -> None: + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + with Session(engine) as db: + fish = Fish(slug="pike", name_ru="Щука", trophy_weight_g=10_000) + water = Waterbody(slug="lake", name_ru="Озеро", unlock_level=1) + spot = Spot(waterbody=water, x=10, y=20) + db.add(CatchReport(fish=fish, waterbody=water, spot=spot, weight_g=1000, reported_at=datetime.now(timezone.utc), source_type=SourceType.user, source_confidence=60, moderation_status=ModerationStatus.approved)) + db.commit() + assert audit_catalog(db)["failures"] == 0 + spot.x = 10_001 + db.commit() + result = audit_catalog(db) + assert result["reports"] == 1 + assert result["invalid_coordinates"] == 1 + assert result["failures"] == 1 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 18b7e5f..76190c8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -111,7 +111,7 @@ - [ ] Создать и проверить ящики `privacy@rf4spotter.ru` и `abuse@rf4spotter.ru`, определить срок реакции на обращения. - [x] Проверить локально публичные abuse-сценарии: шестая заявка блокируется, файл > 8 МБ отклоняется до декодирования, повтор screenshot запрещён, admin queue ограничена 100 строками; нагрузочный прогон остаётся серверным шагом. - [x] Зафиксировать сценарий приёмки открытой альфы, измеримые пороги и стоп-критерии (`docs/open-alpha-acceptance.md`). -- [ ] Согласовать первые категории рекордов, водоёмы и виды рыб. +- [x] Зафиксировать охват открытой альфы: без продуктового allowlist, доступны все корректно загруженные категории, водоёмы и виды рыб из разрешённых источников; ограничения качества и модерации сохраняются. - [ ] Наполнить базу небольшим разрешённым набором реальных данных. - [ ] Провести тестирование с несколькими игроками по подготовленному сценарию. - [ ] Собрать обратную связь по полезности точек, понятности уверенности, форме улова и мобильному интерфейсу. @@ -122,7 +122,7 @@ Технический production-контур, health/readiness, backup/restore и безопасные логи готовы. Следующие пункты выполняются строго по одному: -1. согласовать стартовые категории, водоёмы и виды рыб; +1. наполнить полный разрешённый каталог; проверка целостности `audit-catalog` уже готова; 2. создать и проверить контакты `privacy`/`abuse`; 3. мониторинг, нагрузка, DNS/TLS и production-профиль на целевом сервере. diff --git a/docs/open-alpha-acceptance.md b/docs/open-alpha-acceptance.md index 0401b61..793e6f4 100644 --- a/docs/open-alpha-acceptance.md +++ b/docs/open-alpha-acceptance.md @@ -1,6 +1,6 @@ # Приёмка открытой альфы RF4 Spotter -Версия: 7 сентября 2026 года. Проверка проводится сначала владельцем проекта, затем группой из 5–15 игроков. В пилот входят только согласованные водоёмы, рыбы и источники; неполный staging не публикуется. +Версия: 7 сентября 2026 года. Проверка проводится сначала владельцем проекта, затем открыто реальными пользователями. Продуктового allowlist нет: доступны все категории рекордов, водоёмы и виды рыб, корректно полученные из разрешённых источников. Неполный или непроверенный staging не публикуется. ## Условия открытия @@ -9,7 +9,8 @@ - работают `privacy@rf4spotter.ru` и `abuse@rf4spotter.ru`, назначен ответственный; - опубликованы `/rules` и `/privacy`, форма требует согласия; - заглушки секретов отсутствуют, административные маршруты закрыты двумя уровнями авторизации; -- импортирован небольшой разрешённый набор реальных данных, демоданные отсутствуют. +- импортирован полный доступный разрешённый каталог, демоданные отсутствуют; +- по каждому источнику опубликованы время последнего успешного обновления и состояние ошибки. ## Сценарий игрока @@ -20,12 +21,17 @@ 5. После одобрения точка появляется в выдаче; удаление заявки обезличивает данные и сохраняет аудит. 6. Проверить рекорды и сброс фильтров на desktop и 390 px. +Проверка каталога выполняется по всем загруженным сущностям автоматически: уникальность slug, существование связей, диапазоны координат/веса, отсутствие неподтверждённого staging в публичном API. Ручной smoke использует минимум по одному представителю каждого водоёма и каждой категории рекордов; редкие виды проверяются выборочно, а не исключаются из публикации. + +Перед каждым релизом выполнить `docker compose exec -T api python -m app.cli audit-catalog`. Ненулевой `failures` или exit code 1 блокирует открытие/обновление. + ## Измеримые критерии | Область | Проходной порог | |---|---| | Доступность | axe: 0 critical/serious; Lighthouse accessibility ≥ 95 | | Интерфейс | нет горизонтального overflow на 390 px; ≥ 80% участников завершают поиск без подсказки | +| Каталог | 100% публичных записей имеют валидные связи; фильтры принимают весь загруженный справочник без allowlist | | Понятность | ≥ 80% верно объясняют разницу активности и уверенности | | API | p95 публичных списков ≤ 250 мс; admin queue ≤ 500 мс на пилотном объёме | | Надёжность | readiness успешен; backup моложе 26 часов и проходит checksum |