diff --git a/README.md b/README.md index 1ee75f7..70b6e4e 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ docker compose up --build ## Что реализовано - FastAPI и SQLAlchemy 2; -- PostgreSQL 17 и миграции Alembic до `0010`; +- PostgreSQL 17 и миграции Alembic до `0011`; - идемпотентный seed с двумя точками и свежими демо-уловами; - `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки; - `GET /api/v1/spots/{id}` и `/catches`; diff --git a/apps/api/alembic/versions/0011_query_indexes.py b/apps/api/alembic/versions/0011_query_indexes.py new file mode 100644 index 0000000..4ef23e9 --- /dev/null +++ b/apps/api/alembic/versions/0011_query_indexes.py @@ -0,0 +1,29 @@ +"""Add composite indexes for pilot list and maintenance queries.""" +from alembic import op + +revision = "0011" +down_revision = "0010" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_index("ix_catch_report_activity_lookup", "catch_report", ["moderation_status", "deleted_at", "reported_at"]) + op.create_index("ix_catch_report_spot_feed", "catch_report", ["spot_id", "moderation_status", "deleted_at", "reported_at"]) + op.create_index("ix_catch_report_moderation_queue", "catch_report", ["source_type", "moderation_status", "deleted_at", "reported_at"]) + op.create_index("ix_catch_report_official_records", "catch_report", ["source_type", "caught_at", "weight_g"]) + op.create_index("ix_official_import_source_status_started", "official_record_import", ["source_url", "status", "started_at"]) + op.create_index("ix_external_observation_review_queue", "external_observation", ["status", "source_system", "last_seen_at"]) + op.create_index("ix_submission_attempt_client_created", "submission_attempt", ["client_hash", "created_at"]) + op.create_index("ix_moderation_event_created_at", "moderation_event", ["created_at"]) + + +def downgrade() -> None: + op.drop_index("ix_moderation_event_created_at", table_name="moderation_event") + op.drop_index("ix_submission_attempt_client_created", table_name="submission_attempt") + op.drop_index("ix_external_observation_review_queue", table_name="external_observation") + op.drop_index("ix_official_import_source_status_started", table_name="official_record_import") + op.drop_index("ix_catch_report_official_records", table_name="catch_report") + op.drop_index("ix_catch_report_moderation_queue", table_name="catch_report") + op.drop_index("ix_catch_report_spot_feed", table_name="catch_report") + op.drop_index("ix_catch_report_activity_lookup", table_name="catch_report") diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 9e84f83..941a584 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -94,18 +94,18 @@ def ready(db: Db) -> JSONResponse: @app.get("/api/v1/fishes", response_model=list[FishOut]) -def fishes(db: Db) -> list[Fish]: - return list(db.scalars(select(Fish).order_by(Fish.name_ru))) +def fishes(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[Fish]: + return list(db.scalars(select(Fish).order_by(Fish.name_ru, Fish.id).offset(offset).limit(limit))) @app.get("/api/v1/waterbodies", response_model=list[WaterbodyOut]) -def waterbodies(db: Db) -> list[Waterbody]: - return list(db.scalars(select(Waterbody).order_by(Waterbody.name_ru))) +def waterbodies(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[Waterbody]: + return list(db.scalars(select(Waterbody).order_by(Waterbody.name_ru, Waterbody.id).offset(offset).limit(limit))) @app.get("/api/v1/baits", response_model=list[BaitOut]) -def baits(db: Db) -> list[Bait]: - return list(db.scalars(select(Bait).order_by(Bait.name))) +def baits(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[Bait]: + return list(db.scalars(select(Bait).order_by(Bait.name, Bait.id).offset(offset).limit(limit))) @app.get("/api/v1/activity", response_model=list[ActivityOut]) @@ -119,7 +119,11 @@ def activity( if hours not in {6, 12, 24, 72}: raise HTTPException(status_code=422, detail="hours must be one of: 6, 12, 24, 72") rows = activity_rows(db, hours=hours, waterbody=waterbody, fish=fish, method=method) - keys = {"activity": lambda r: r.activity_score, "confidence": lambda r: r.confidence_score, "freshness": lambda r: r.last_confirmed_at} + keys = { + "activity": lambda r: (r.activity_score, r.confidence_score, r.last_confirmed_at, str(r.spot_id)), + "confidence": lambda r: (r.confidence_score, r.activity_score, r.last_confirmed_at, str(r.spot_id)), + "freshness": lambda r: (r.last_confirmed_at, r.activity_score, r.confidence_score, str(r.spot_id)), + } rows.sort(key=keys[sort], reverse=True) return rows[offset:offset + limit] @@ -145,7 +149,7 @@ def spot_detail(spot_id: UUID, db: Db) -> SpotOut: @app.get("/api/v1/spots/{spot_id}/catches", response_model=list[CatchOut]) def spot_catches(spot_id: UUID, db: Db, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[CatchOut]: _spot_or_404(db, spot_id) - reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.bait)).where(CatchReport.spot_id == spot_id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at.desc()).offset(offset).limit(limit))) + reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.bait)).where(CatchReport.spot_id == spot_id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at.desc(), CatchReport.id.desc()).offset(offset).limit(limit))) return [CatchOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, caught_at=r.caught_at, reported_at=r.reported_at, retrieve_method=r.retrieve_method, retrieve_speed=r.retrieve_speed) for r in reports] @@ -160,9 +164,9 @@ def records( query = query.join(CatchReport.fish).where(Fish.slug == fish) if waterbody: query = query.join(CatchReport.waterbody).where(Waterbody.slug == waterbody) - items = list(db.scalars(query.order_by(CatchReport.caught_at.desc(), CatchReport.weight_g.desc()).offset(offset).limit(limit))) if category: - items = [item for item in items if (item.raw_payload or {}).get("category") == category] + query = query.where(CatchReport.raw_payload["category"].as_string() == category) + items = list(db.scalars(query.order_by(CatchReport.caught_at.desc(), CatchReport.weight_g.desc(), CatchReport.id.desc()).offset(offset).limit(limit))) return [OfficialRecordOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, waterbody=r.waterbody.name_ru, bait=r.bait.name if r.bait else None, player_name=r.player_name, record_date=r.caught_at, category=(r.raw_payload or {}).get("category"), region=(r.raw_payload or {}).get("region"), source_url=r.source_url) for r in items] @@ -174,8 +178,8 @@ def _admin(authorization: Annotated[str | None, Header()] = None) -> str: @app.get("/api/v1/imports", response_model=list[ImportRunOut]) -def imports(db: Db, limit: int = Query(20, ge=1, le=100)) -> list[OfficialRecordImport]: - return list(db.scalars(select(OfficialRecordImport).order_by(OfficialRecordImport.started_at.desc()).limit(limit))) +def imports(db: Db, limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[OfficialRecordImport]: + return list(db.scalars(select(OfficialRecordImport).order_by(OfficialRecordImport.started_at.desc(), OfficialRecordImport.id.desc()).offset(offset).limit(limit))) @app.get("/api/v1/admin/imports", response_model=list[ImportRunOut]) @@ -221,7 +225,8 @@ def _external_out(item: ExternalObservation) -> ExternalObservationOut: @app.get("/api/v1/admin/external-observations", response_model=list[ExternalObservationOut]) def admin_external_observations( - db: Db, _: Annotated[str, Depends(_admin)], status: str | None = None, + db: Db, _: Annotated[str, Depends(_admin)], + status: Literal["staged", "mapped", "ready", "published", "rejected"] | None = None, source_system: str | None = None, limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), ) -> list[ExternalObservationOut]: query = select(ExternalObservation).options( @@ -231,7 +236,7 @@ def admin_external_observations( query = query.where(ExternalObservation.status == status) if source_system: query = query.where(ExternalObservation.source_system == source_system) - items = db.scalars(query.order_by(ExternalObservation.last_seen_at.desc()).offset(offset).limit(limit)) + items = db.scalars(query.order_by(ExternalObservation.last_seen_at.desc(), ExternalObservation.id.desc()).offset(offset).limit(limit)) return [_external_out(item) for item in items] @@ -332,8 +337,8 @@ def add_screenshot( @app.get("/api/v1/admin/catch-reports", response_model=list[AdminCatchReportOut]) -def admin_reports(db: Db, _: Annotated[str, Depends(_admin)], status: ModerationStatus = ModerationStatus.pending, limit: int = Query(50, ge=1, le=100)) -> list[AdminCatchReportOut]: - reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.waterbody), joinedload(CatchReport.spot), joinedload(CatchReport.bait)).where(CatchReport.source_type == SourceType.user, CatchReport.moderation_status == status, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at).limit(limit))) +def admin_reports(db: Db, _: Annotated[str, Depends(_admin)], status: ModerationStatus = ModerationStatus.pending, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[AdminCatchReportOut]: + reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.waterbody), joinedload(CatchReport.spot), joinedload(CatchReport.bait)).where(CatchReport.source_type == SourceType.user, CatchReport.moderation_status == status, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at, CatchReport.id).offset(offset).limit(limit))) return [AdminCatchReportOut(id=r.id, fish=r.fish.name_ru, waterbody=r.waterbody.name_ru, coordinates=f"{r.spot.x}:{r.spot.y}" if r.spot else "—", weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, reported_at=r.reported_at, moderation_status=r.moderation_status.value, comment=(r.raw_payload or {}).get("comment"), screenshot_url=signed_screenshot_url(r.screenshot_key) if r.screenshot_key else None) for r in reports] diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index a2fda49..ce89b80 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -56,6 +56,14 @@ def test_invalid_period_is_rejected() -> None: assert client.get("/api/v1/activity?sort=unknown").status_code == 422 +def test_list_pagination_and_filter_validation() -> None: + assert client.get("/api/v1/fishes?limit=0").status_code == 422 + assert client.get("/api/v1/fishes?limit=1&offset=0").status_code == 200 + headers = {"Authorization": "Bearer change-me-in-production"} + assert client.get("/api/v1/admin/external-observations?status=unknown", headers=headers).status_code == 422 + assert client.get("/api/v1/admin/catch-reports?offset=-1", headers=headers).status_code == 422 + + def test_liveness_does_not_probe_dependencies() -> None: response = client.get("/health?token=must-not-be-logged") assert response.json() == {"status": "ok"} @@ -80,6 +88,22 @@ def test_records_list_is_empty_before_import() -> None: assert response.json() == [] +def test_record_category_filter_is_applied_before_pagination() -> None: + with Session(engine) as db: + fish = db.scalar(select(Fish).where(Fish.slug == "pike")) + waterbody = db.scalar(select(Waterbody).where(Waterbody.slug == "test-lake")) + now = datetime.now(timezone.utc) - timedelta(days=30) + db.add_all([ + CatchReport(fish=fish, waterbody=waterbody, weight_g=9000, caught_at=now, reported_at=now, source_type=SourceType.official_record, source_confidence=100, moderation_status=ModerationStatus.approved, raw_payload={"category": "other"}), + CatchReport(fish=fish, waterbody=waterbody, weight_g=8000, caught_at=now - timedelta(days=1), reported_at=now, source_type=SourceType.official_record, source_confidence=100, moderation_status=ModerationStatus.approved, raw_payload={"category": "wanted"}), + ]) + db.commit() + response = client.get("/api/v1/records?category=wanted&limit=1") + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()[0]["category"] == "wanted" + + def test_user_report_requires_moderation_before_activity() -> None: created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 77, "y": 88, "weight_g": 5500, "bait_name": "Новая приманка", "player_name": "Reporter"}) assert created.status_code == 201 diff --git a/deploy/test-production-bootstrap.sh b/deploy/test-production-bootstrap.sh index cf20897..15cd993 100755 --- a/deploy/test-production-bootstrap.sh +++ b/deploy/test-production-bootstrap.sh @@ -26,7 +26,9 @@ 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')" = "0010" +test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select version_num from alembic_version')" = "0011" +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" test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from waterbody')" = "2" test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from catch_report')" = "0" diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0c8f8fa..7db77f6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -61,10 +61,10 @@ - [x] Автоматизировать ежедневную цепочку backup → dry-run → retention с блокировкой параллельного запуска; ограничить Docker JSON-логи пятью файлами по 10 МБ на сервис. - [x] Добавить структурированные JSON-логи без пользовательских секретов и персональных технических данных (whitelist полей, redaction, request ID; Uvicorn access-log отключён). - [x] Добавить Gitea Actions CI: backend tests, Astro check/build, E2E и применение всех миграций на чистой PostgreSQL; сохранять логи Compose и Playwright-артефакты при падении (`.gitea/workflows/ci.yml`). -- [x] Добавить отдельный тест полного bootstrap: пустые production volumes → миграции `0010` → seed без демо-уловов → readiness → браузерная отправка и проверка moderation API (`deploy/test-production-bootstrap.sh`, 6 сентября 2026). +- [x] Добавить отдельный тест полного bootstrap: пустые production volumes → миграции `0011` → seed без демо-уловов → readiness → браузерная отправка и проверка moderation API (`deploy/test-production-bootstrap.sh`, 7 сентября 2026). - [x] Сделать seed устойчивым к частично заполненной БД: справочники досеиваются независимо, демо-уловы идемпотентны и принудительно отключены в production; повторный/частичный запуск покрыт конфигурационными и интеграционными проверками. -- [ ] Проверить списочные API по требованию раздела 12: пагинация, предсказуемая сортировка и валидация фильтров для справочников, импортов, модерации и внешнего staging. -- [ ] Проверить необходимые индексы PostgreSQL и планы запросов для activity, модерации, дедупликации и очистки rate limit; зафиксировать допустимый бюджет запросов пилота. +- [x] Проверить списочные API по требованию раздела 12: все выдачи имеют ограниченные `limit`/`offset`, детерминированный tie-breaker и типизированные фильтры; фильтр категории рекордов перенесён до пагинации. +- [x] Добавить составные индексы PostgreSQL для activity, модерации, официальных рекордов, staging, импорта, аудита и очистки rate limit (миграция `0011`); бюджет и процедура проверки планов зафиксированы в `docs/query-performance.md`. - [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`. @@ -119,12 +119,10 @@ Технический production-контур, health/readiness, backup/restore и безопасные логи готовы. Следующие пункты выполняются строго по одному: -1. защита официального импорта от конкурентных запусков; -2. пагинация/сортировка списочных API и индексы PostgreSQL; -3. UI/UX-пакеты B–D: мобильная главная, форма и рекорды; -4. accessibility/admin safety и Lighthouse; -5. мониторинг, DNS/TLS и проверка production-профиля на целевом сервере; -6. финальное обновление README, лицензия кода и политика данных. +1. UI/UX-пакеты B–D: мобильная главная, форма и рекорды; +2. accessibility/admin safety и Lighthouse; +3. мониторинг, DNS/TLS и проверка production-профиля на целевом сервере; +4. финальное обновление README, лицензия кода и политика данных. После каждого пункта необходимо: diff --git a/docs/query-performance.md b/docs/query-performance.md new file mode 100644 index 0000000..56385e8 --- /dev/null +++ b/docs/query-performance.md @@ -0,0 +1,38 @@ +# Бюджет запросов для альфа-пилота + +Дата фиксации: 7 сентября 2026 года. + +## Контракт списочных API + +Все списочные endpoint'ы принимают ограниченный `limit` и неотрицательный `offset`. Справочники ограничены 500 строками, публичные и административные журналы — 100–200 строками. Сортировка всегда имеет уникальный `id` последним ключом, поэтому соседние страницы не меняются местами при одинаковых датах или названиях. Значения перечислимых фильтров проверяет FastAPI; неподдерживаемое значение возвращает `422`. + +Фильтры применяются в SQL до `offset` и `limit`. Это особенно важно для `GET /api/v1/records?category=...`: фильтрация JSON-поля после пагинации могла возвращать пустую страницу при наличии подходящих записей. + +## Индексы + +Миграция `0011` добавляет составные индексы для основных путей чтения: + +- activity и лента точки — статус модерации, удаление, время и точка; +- очередь модерации — источник, статус, удаление и время; +- официальные рекорды — источник, дата и вес; +- журнал импорта — источник, статус и время запуска; +- staging — статус, система-источник и время последнего наблюдения; +- rate limit — отпечаток клиента и время попытки; +- retention аудита — время события модерации. + +## Измеримый бюджет + +На сервере альфа-пилота при объёме до 100 000 уловов и до 100 000 staging-наблюдений принимаются следующие server-side цели без учёта сети и браузерного рендера: + +- p95 публичных списков и activity — не более 250 мс; +- p95 административных очередей — не более 500 мс; +- один запрос очистки rate limit или retention-пакет — не более 2 с; +- ни один интерактивный запрос не должен читать более 10 000 строк фактов по `Rows Removed by Filter`. + +Это стартовый эксплуатационный бюджет, а не результат синтетического бенчмарка. Планы на пустой bootstrap-БД не показательны: PostgreSQL обоснованно выбирает последовательное чтение маленьких таблиц. + +## Проверка после загрузки пилотных данных + +После наполнения выполнить `EXPLAIN (ANALYZE, BUFFERS)` для activity, moderation queue, records, staging queue и удаления старых submission attempts. Проверять фактическое время, `Rows Removed by Filter`, объём buffers и соответствие выбранного индекса фильтрам. Если таблица превышает 10 000 строк, а план остаётся последовательным и выходит за бюджет, сохранить план в журнал релиза и скорректировать индекс или форму запроса до открытия альфы. + +Bootstrap-тест отдельно проверяет, что Alembic дошёл до `0011` и все восемь составных индексов созданы на чистой PostgreSQL.