diff --git a/README.md b/README.md index f8214e3..419afda 100644 --- a/README.md +++ b/README.md @@ -6,15 +6,21 @@ RF4 Spotter — неофициальный сервис свежих точек ## Статус разработки -Функциональный MVP и локальный production-контур готовятся к открытой альфе: официальный импорт, пользовательские заявки, модерация, объяснимый индекс, staging внешних источников, адаптивный Astro UI, миграции, резервное копирование, retention, мониторинг и security/accessibility-проверки реализованы. На всех страницах подключён компактный баннер открытой альфы со ссылками на статус, правила и отправку улова. Автоматические импорты внешних источников выключены. Публичный запуск блокируют покупка и настройка сервера, DNS/TLS, реальные секреты, внешний backup, канал уведомлений и публичные страницы правил/privacy; публичный адрес обратной связи ещё не задан. +В очереди внешних наблюдений доступна кнопка «Подсказать соответствия»: она показывает ранее подтверждённые рыбу и водоём. Значения формы не меняются автоматически; сопоставление и публикация подтверждаются отдельно. + +Повторный импорт изменённой опубликованной записи переводит её на ручную проверку и снимает прежний улов с активности (с учётом TTL кэша). После сопоставления и подтверждения обновляется тот же улов; дубликат не создаётся. Автоматическое обнаружение удалённых оригиналов пока не реализовано. + +Идёт исправление аудита: актуальные изменения и ограничения перечислены в [AUDIT_FIXES.md](docs/AUDIT_FIXES.md). Production Compose включает community scheduler; страницы rules/privacy реализованы. Для запуска остаются сервер, DNS/TLS, секреты, внешний backup и контакты. Шкала 72 часов использует полную выборку по времени поступления; одинаковые поля разных источников больше не считаются доказательством одного события. Фоновая публикация обновляет кэш API в пределах TTL, не мгновенно. + +Функциональный MVP и локальный production-контур готовятся к открытой альфе: официальный импорт, пользовательские заявки, модерация, объяснимый индекс, staging внешних источников, адаптивный Astro UI, миграции, резервное копирование, retention, мониторинг и security/accessibility-проверки реализованы. На всех страницах подключён компактный баннер открытой альфы со ссылками на статус, правила и отправку улова. В production Compose включён community scheduler; локально он запускается отдельным профилем. Публичный запуск блокируют покупка и настройка сервера, DNS/TLS, реальные секреты, внешний backup, канал уведомлений; публичный адрес обратной связи ещё не задан. RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изолированный staging. Полные записи с ранее подтверждёнными алиасами источника публикуются автоматически; новые соответствия и неполные записи остаются на ручной проверке. Admin API предлагает точные ранее подтверждённые алиасы отдельно от mapping-действия и запрещает молча переназначать alias другой сущности. Для разрешённых community-источников действует интервал не менее 30 минут на источник. Открытая альфа не использует продуктовый allowlist: интерфейс показывает весь корректно загруженный разрешённый каталог, сохраняя требования полноты и модерации. -На сайте у каждой записи отображается источник, а у агрегированной активности — все вошедшие в расчёт источники. Неполные community-наблюдения публикуются сразу в отдельной ленте «Полевые сигналы» с предупреждением и перечнем отсутствующих полей; до подтверждения полноты они не влияют на индекс клёва. Лента раскрывается серверной кнопкой «Показать ещё», сохраняет выбранные фильтры и ограничена 48 сигналами на страницу. Совпадающие рыба, водоём, координаты и вес объединяются визуально, при этом карточка сохраняет все уникальные ссылки на исходные наблюдения. Sidebar лидера скрывается при единственном результате, чтобы не повторять ту же карточку. +На сайте у каждой записи отображается источник, а у агрегированной активности — все вошедшие в расчёт источники. Неполные community-наблюдения публикуются сразу в отдельной ленте «Полевые сигналы» с предупреждением и перечнем отсутствующих полей; до подтверждения полноты они не влияют на индекс клёва. Лента раскрывается серверной кнопкой «Показать ещё», сохраняет выбранные фильтры и ограничена 48 сигналами на страницу. Визуально объединяются только повторы одного ID источника; похожие записи разных площадок остаются самостоятельными наблюдениями. Sidebar лидера скрывается при единственном результате, чтобы не повторять ту же карточку. Базовый SEO-контур готов для `rf4spotter.ru`: страницы имеют уникальные метаданные, canonical, Open Graph/Twitter Card, фирменное изображение 1200×630 и JSON-LD; доступны динамические `/robots.txt` и `/sitemap.xml`, административные и ошибочные страницы закрыты от индексации, добавлена собственная страница 404. Индексируемые каталоги рыб и водоёмов, detail-страницы и сочетания водоём + рыба строятся из актуального разрешённого справочника и включаются в sitemap. Подключены фирменные favicon/app icons, web manifest и production-кэширование статических ресурсов. Карточки активности показывают единый паспорт данных: источники, свежесть, полноту и уровень доверия. На `/status` опубликована легенда цветов всех источников и статусов качества. -Публичные точки используют постоянные читаемые адреса вида `/spots/kuori-85x92`; старые UUID-адреса остаются совместимыми и перенаправляются на канонический URL. На странице точки координаты дополнительно показаны фирменным радаром, который не имитирует отсутствующую географию водоёма, а уловы за 72 часа — шкалой-леской с 12-часовым шагом. Каждый улов показывает источник, относительную свежесть и точную дату в подсказке. Карточки активности и каталог дополнены лёгкими SVG-силуэтами рыб без внешних графических зависимостей. Пустые и аварийные состояния используют собственную CSS-иллюстрацию поплавка; анимация учитывает системное ограничение движения. +Публичные точки используют постоянные читаемые адреса вида `/spots/kuori-85x92`; старые UUID-адреса остаются совместимыми и перенаправляются на канонический URL. На странице точки координаты дополнительно показаны фирменным радаром, который не имитирует отсутствующую географию водоёма, а уловы за 72 часа — шкалой-леской с 12-часовым шагом. Каждый улов показывает источник, относительную свежесть и точное время UTC; время получения явно отделено от времени улова. Карточки активности и каталог дополнены лёгкими SVG-силуэтами рыб без внешних графических зависимостей. Пустые и аварийные состояния используют собственную CSS-иллюстрацию поплавка; анимация учитывает системное ограничение движения. Все пять community-парсеров подключены к отдельному scheduler-процессу. Состояние запусков и ошибок хранится в PostgreSQL, параллельный запуск одного источника блокируется, минимальный интервал жёстко ограничен 1800 секундами. Локально процесс включается профилем `docker compose --profile scheduler up -d`; detail-URL RF4MAP/RF4 Posts задаются переменными окружения. @@ -88,7 +94,7 @@ docker compose up --build Контейнер API сам выполняет `alembic upgrade head`, затем идемпотентный seed. PostgreSQL хранит данные в именованном volume `postgres_data`, а MinIO — в `minio_data`. Compose ожидает readiness PostgreSQL и MinIO перед API, а API-контейнер проверяет `/ready`. Версия и commit SHA задаются через `APP_VERSION`/`APP_REVISION`; те же значения доступны администратору в `/api/v1/admin/diagnostics`. Официальный импорт по умолчанию необязателен; при включённом scheduler установите `OFFICIAL_IMPORT_REQUIRED=true`, тогда отсутствующий, неуспешный или просроченный запуск сделает readiness отрицательным. -API и scheduler пишут по одной JSON-записи на событие. HTTP-лог содержит только сгенерированный `request_id`, метод, путь без query string, статус и длительность; IP, заголовок авторизации и пользовательский payload не журналируются. `X-Request-ID` возвращается клиенту. Стандартный access-log Uvicorn отключён. Уровень управляется `LOG_LEVEL`. Публичный агрегат активности кэшируется в памяти процесса на 20 секунд и очищается после публикации, модерации или удаления; `X-Cache` показывает `HIT`/`MISS`. Защищённый `/api/v1/admin/diagnostics` скачивает JSON только с идентификатором сборки и агрегированными счётчиками, без имён игроков, исходных URL, payload и ошибок парсеров. +API и scheduler пишут по одной JSON-записи на событие. HTTP-лог содержит только сгенерированный `request_id`, метод, путь без query string, статус и длительность; IP, заголовок авторизации и пользовательский payload не журналируются. `X-Request-ID` возвращается клиенту. Стандартный access-log Uvicorn отключён. Уровень управляется `LOG_LEVEL`. Публичный агрегат активности кэшируется в памяти процесса на 20 секунд (до 128 ключей) и очищается после публикации, модерации или удаления через этот процесс API; изменения scheduler видны после TTL; `X-Cache` показывает `HIT`/`MISS`. Защищённый `/api/v1/admin/diagnostics` скачивает JSON только с идентификатором сборки и агрегированными счётчиками, без имён игроков, исходных URL, payload и ошибок парсеров. Остановка: diff --git a/apps/api/app/activity.py b/apps/api/app/activity.py index 01928e9..970af63 100644 --- a/apps/api/app/activity.py +++ b/apps/api/app/activity.py @@ -34,13 +34,13 @@ def activity_rows( CatchReport.reported_at >= now - timedelta(hours=hours), ) ) - reports = list(session.scalars(query)) if waterbody: - reports = [r for r in reports if r.waterbody.slug == waterbody] + query = query.where(CatchReport.waterbody.has(slug=waterbody)) if fish: - reports = [r for r in reports if r.fish.slug == fish] + query = query.where(CatchReport.fish.has(slug=fish)) if method: - reports = [r for r in reports if r.fishing_method == method] + query = query.where(CatchReport.fishing_method == method) + reports = list(session.scalars(query)) groups: dict[tuple[object, object], list[CatchReport]] = {} for report in reports: diff --git a/apps/api/app/community_importer.py b/apps/api/app/community_importer.py index 17725bf..119d26c 100644 --- a/apps/api/app/community_importer.py +++ b/apps/api/app/community_importer.py @@ -9,7 +9,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from .community_review import publish_observation -from .models import DataSource, ExternalEntityAlias, ExternalObservation +from .models import DataSource, ExternalEntityAlias, ExternalObservation, ModerationStatus SOURCE_DEFAULTS = { @@ -77,6 +77,18 @@ def stage_observations( session.add(observation) created += 1 else: + # Preserve the published snapshot, but withdraw it from activity until + # a moderator confirms the changed source record. + changed = any(getattr(observation, key) != values[key] for key in ( + "source_url", "fish_name", "fish_external_id", "waterbody_name", + "waterbody_external_id", "x", "y", "weight_g", + )) or observation.payload != payload + if observation.status != "rejected" and changed and observation.catch_report is not None: + observation.catch_report.moderation_status = ModerationStatus.pending + observation.status = "staged" + observation.fish = None + observation.waterbody = None + observation.review_note = "Source record changed; manual mapping and publication required" for key, value in values.items(): setattr(observation, key, value) updated += 1 @@ -90,6 +102,8 @@ def stage_observations( def _auto_publish(session: Session, observation: ExternalObservation) -> bool: """Publish only complete observations covered by previously reviewed aliases.""" if ( + observation.catch_report_id is not None + or observation.status not in {"staged", "mapped", "ready"} or not observation.source.enabled or observation.fish_external_id is None diff --git a/apps/api/app/community_review.py b/apps/api/app/community_review.py index c2ad479..76cc77c 100644 --- a/apps/api/app/community_review.py +++ b/apps/api/app/community_review.py @@ -59,8 +59,10 @@ def reject_observation(session: Session, observation: ExternalObservation, *, re def publish_observation(session: Session, observation: ExternalObservation) -> CatchReport: - if observation.catch_report is not None: + if observation.catch_report is not None and observation.status == "published": return observation.catch_report + if observation.status == "rejected": + raise ExternalReviewError("rejected observation must be mapped again before publication") if observation.fish is None or observation.waterbody is None or not _complete(observation): raise ExternalReviewError("fish, waterbody, coordinates and weight are required for publication") spot = session.scalar(select(Spot).where( @@ -71,7 +73,7 @@ def publish_observation(session: Session, observation: ExternalObservation) -> C session.add(spot) bait = _bait(session, observation.payload.get("bait")) now = datetime.now(timezone.utc) - report = CatchReport( + values = dict( fish=observation.fish, waterbody=observation.waterbody, spot=spot, bait=bait, weight_g=observation.weight_g, fishing_method=observation.payload.get("fishing_method"), @@ -95,6 +97,12 @@ def publish_observation(session: Session, observation: ExternalObservation) -> C "original": observation.payload, }, ) + report = observation.catch_report + if report is None: + report = CatchReport(**values) + else: + for key, value in values.items(): + setattr(report, key, value) session.add(report) session.flush() observation.catch_report = report diff --git a/apps/api/app/community_scheduler.py b/apps/api/app/community_scheduler.py index 090d32b..98d04ad 100644 --- a/apps/api/app/community_scheduler.py +++ b/apps/api/app/community_scheduler.py @@ -42,13 +42,15 @@ def run_source(source_system: str, *, now: datetime | None = None) -> bool: source = session.get(DataSource, source_system) if source is None or not source.enabled: return False + # Lock before reading cooldown: committing the reservation makes it visible + # to the next contender before releasing this transaction lock. + if session.bind and session.bind.dialect.name == "postgresql" and not session.scalar(text("select pg_try_advisory_xact_lock(hashtext(:key))"), {"key": f"community:{source_system}"}): + return False recent = list(session.scalars(select(CommunityImportRun).where(CommunityImportRun.source_system == source_system).order_by(CommunityImportRun.started_at.desc()).limit(8))) latest = recent[0].started_at if recent else None delay = retry_delay([run.status for run in recent]) if latest and (latest if latest.tzinfo else latest.replace(tzinfo=timezone.utc)) > current - timedelta(seconds=delay): return False - if session.bind and session.bind.dialect.name == "postgresql" and not session.scalar(text("select pg_try_advisory_xact_lock(hashtext(:key))"), {"key": f"community:{source_system}"}): - return False run = CommunityImportRun(source_system=source_system, source_url=url, started_at=current, status="running") session.add(run); session.commit() try: @@ -57,6 +59,7 @@ def run_source(source_system: str, *, now: datetime | None = None) -> bool: created, updated = stage_observations(session, [asdict(item) for item in records]) run.status, run.rows_seen, run.rows_created, run.rows_updated = "success", len(records), created, updated except Exception as exc: + session.rollback() run.status, run.error_summary = "failed", f"{type(exc).__name__}: {str(exc)[:500]}" logger.exception("community import failed", extra={"event":"community_import_failed", "source_system":source_system}) run.finished_at = datetime.now(timezone.utc); session.commit() diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 569597d..31d4dfc 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -109,6 +109,17 @@ def baits(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0, return list(db.scalars(select(Bait).order_by(Bait.name, Bait.id).offset(offset).limit(limit))) +@app.get("/api/v1/public-spot-pages") +def public_spot_pages(db: Db, limit: int = Query(500, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[str]: + rows = db.execute(select(Waterbody.slug, Spot.x, Spot.y, Fish.slug) + .select_from(CatchReport).join(Spot, CatchReport.spot_id == Spot.id) + .join(Waterbody, Spot.waterbody_id == Waterbody.id).join(Fish, CatchReport.fish_id == Fish.id) + .where(CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None)) + .distinct().order_by(Waterbody.slug, Spot.x, Spot.y, Fish.slug).offset(offset).limit(limit)) + return [path for water, x, y, fish in rows for path in + (f"/spots/{water}-{x}x{y}", f"/waterbodies/{water}/{fish}")] + + @app.get("/api/v1/activity", response_model=list[ActivityOut]) def activity( db: Db, response: Response, hours: int = Query(24), @@ -119,7 +130,8 @@ def activity( ) -> list[ActivityOut]: if hours not in {6, 12, 24, 72}: raise HTTPException(status_code=422, detail="hours must be one of: 6, 12, 24, 72") - response.headers["Cache-Control"] = f"public, max-age={settings.public_cache_seconds}" + response.headers["Cache-Control"] = "no-store" + generation = public_cache.generation() cache_key = ("activity", hours, waterbody, fish, method, sort, limit, offset) cached = public_cache.get(cache_key, settings.public_cache_seconds) if cached is not None: @@ -133,7 +145,7 @@ def activity( } rows.sort(key=keys[sort], reverse=True) response.headers["X-Cache"] = "MISS" - return public_cache.set(cache_key, rows[offset:offset + limit]) + return public_cache.set(cache_key, rows[offset:offset + limit], generation=generation) def _spot_or_404(db: Session, spot_id: UUID) -> Spot: @@ -174,6 +186,24 @@ def spot_catches(spot_id: UUID, db: Db, limit: int = Query(50, ge=1, le=100), of 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, source_system=_report_source(r), source_url=r.source_url) for r in reports] +@app.get("/api/v1/spots/{spot_id}/timeline") +def spot_timeline(spot_id: UUID, db: Db) -> list[dict]: + _spot_or_404(db, spot_id) + now = datetime.now(timezone.utc) + buckets = [] + for index in range(6): + start = now - timedelta(hours=(6 - index) * 12) + end = start + timedelta(hours=12) + count = db.scalar(select(func.count()).select_from(CatchReport).where( + CatchReport.spot_id == spot_id, + CatchReport.moderation_status == ModerationStatus.approved, + CatchReport.deleted_at.is_(None), + CatchReport.reported_at >= start, CatchReport.reported_at < end, + )) or 0 + buckets.append({"start": start.isoformat(), "end": end.isoformat(), "count": count}) + return buckets + + def _report_source(report: CatchReport) -> str: provenance = (report.raw_payload or {}).get("provenance", {}) if isinstance(provenance, dict) and provenance.get("source_system"): diff --git a/apps/api/app/public_cache.py b/apps/api/app/public_cache.py index 4fa46bc..dd242d3 100644 --- a/apps/api/app/public_cache.py +++ b/apps/api/app/public_cache.py @@ -7,9 +7,15 @@ from typing import Any class PublicResponseCache: - def __init__(self) -> None: + def __init__(self, max_entries: int = 128) -> None: self._items: dict[tuple[Any, ...], tuple[float, Any]] = {} self._lock = Lock() + self._max_entries = max_entries + self._generation = 0 + + def generation(self) -> int: + with self._lock: + return self._generation def get(self, key: tuple[Any, ...], ttl_seconds: int) -> Any | None: with self._lock: @@ -19,13 +25,18 @@ class PublicResponseCache: return None return deepcopy(item[1]) - def set(self, key: tuple[Any, ...], value: Any) -> Any: + def set(self, key: tuple[Any, ...], value: Any, *, generation: int | None = None) -> Any: with self._lock: + if generation is not None and generation != self._generation: + return value + if key not in self._items and len(self._items) >= self._max_entries: + self._items.pop(next(iter(self._items))) self._items[key] = (monotonic(), deepcopy(value)) return value def invalidate(self) -> None: with self._lock: + self._generation += 1 self._items.clear() diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index ffada81..6bead1f 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -57,6 +57,37 @@ def test_invalid_period_is_rejected() -> None: assert client.get("/api/v1/activity?sort=unknown").status_code == 422 +def test_timeline_includes_more_than_catch_page_and_sitemap_includes_old_spots() -> None: + with Session(engine) as db: + fish = db.scalar(select(Fish).where(Fish.slug == "pike")) + water = db.scalar(select(Waterbody).where(Waterbody.slug == "test-lake")) + spot = Spot(waterbody=water, x=901, y=902) + db.add(spot) + db.flush() + spot_id = spot.id + reports = [CatchReport(fish=fish, waterbody=water, spot=spot, weight_g=1000, + reported_at=datetime.now(timezone.utc) - timedelta(hours=1 if i < 60 else 100), + source_type=SourceType.manual_import, source_confidence=70, + moderation_status=ModerationStatus.approved) for i in range(61)] + db.add_all(reports) + db.commit() + try: + result = client.get(f"/api/v1/spots/{spot_id}/timeline") + assert result.status_code == 200 + assert sum(row["count"] for row in result.json()) == 60 + assert len(client.get(f"/api/v1/spots/{spot_id}/catches").json()) == 50 + for report in reports: + report.reported_at = datetime.now(timezone.utc) - timedelta(days=10) + db.commit() + assert "/spots/test-lake-901x902" in client.get("/api/v1/public-spot-pages").json() + finally: + for report in reports: + db.delete(report) + db.flush() + db.delete(spot) + db.commit() + + 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 diff --git a/apps/api/tests/test_community_importer.py b/apps/api/tests/test_community_importer.py index ad1829c..b41e05c 100644 --- a/apps/api/tests/test_community_importer.py +++ b/apps/api/tests/test_community_importer.py @@ -8,7 +8,7 @@ from sqlalchemy import create_engine, func, select from sqlalchemy.orm import Session from app.community_importer import CommunityImportError, stage_observations -from app.community_review import ExternalReviewError, map_observation, suggest_aliases +from app.community_review import ExternalReviewError, map_observation, publish_observation, suggest_aliases from app.database import Base 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 @@ -111,6 +111,33 @@ def test_complete_observation_with_reviewed_aliases_is_published(db: Session) -> assert db.scalar(select(func.count()).select_from(CatchReport)) == 1 +def test_changed_published_record_requires_review_and_reuses_report(db: Session) -> None: + fish = Fish(slug="pike", name_ru="Щука") + water = Waterbody(slug="test-lake", name_ru="Тестовое озеро") + db.add_all([fish, water]) + db.commit() + stage_observations(db, [record() | {"weight_g": 5000}]) + item = db.scalar(select(ExternalObservation)) + map_observation(db, item, fish, water) + report = publish_observation(db, item) + report_id = report.id + stage_observations(db, [record() | {"weight_g": 6000}]) + assert item.status == "staged" + assert report.moderation_status.value == "pending" + assert report.weight_g == 5000 + assert item.weight_g == 6000 + stage_observations(db, [record() | {"weight_g": 6000}]) + assert item.status == "staged" + with pytest.raises(ExternalReviewError): + publish_observation(db, item) + map_observation(db, item, fish, water) + updated = publish_observation(db, item) + assert updated.id == report_id + assert updated.weight_g == 6000 + assert updated.moderation_status.value == "approved" + 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="Щука") diff --git a/apps/api/tests/test_public_cache.py b/apps/api/tests/test_public_cache.py index a61fa01..890aef0 100644 --- a/apps/api/tests/test_public_cache.py +++ b/apps/api/tests/test_public_cache.py @@ -9,3 +9,15 @@ def test_cache_copies_values_and_invalidates() -> None: assert cache.get(("activity",), 20) == [{"score": 10}] cache.invalidate() assert cache.get(("activity",), 20) is None + + +def test_cache_bounds_memory_and_rejects_result_started_before_invalidation() -> None: + cache = PublicResponseCache(max_entries=2) + generation = cache.generation() + cache.set((1,), [1]) + cache.set((2,), [2]) + cache.set((3,), [3]) + assert cache.get((1,), 20) is None + cache.invalidate() + cache.set((4,), [4], generation=generation) + assert cache.get((4,), 20) is None diff --git a/apps/web/src/components/ActivityCard.astro b/apps/web/src/components/ActivityCard.astro index 300177f..2b83051 100644 --- a/apps/web/src/components/ActivityCard.astro +++ b/apps/web/src/components/ActivityCard.astro @@ -16,7 +16,7 @@ const limited = item.catches < 3;
{item.x}:{item.y} {ago(item.last_confirmed_at)}

{item.explanation}

- +
Работает сейчас{item.best_bait ?? "не указана"}
{item.catches}{plural(item.catches, ["улов", "улова", "уловов"])}
{item.unique_players}{plural(item.unique_players, ["игрок", "игрока", "игроков"])}
{kg(item.average_weight_g)}средний вес
{item.confidence_score}%уверенность
diff --git a/apps/web/src/components/ActivityTimeline.astro b/apps/web/src/components/ActivityTimeline.astro index 2f3d4aa..72ef457 100644 --- a/apps/web/src/components/ActivityTimeline.astro +++ b/apps/web/src/components/ActivityTimeline.astro @@ -1,19 +1,10 @@ --- -import type { Catch } from "../lib/api"; -const { catches } = Astro.props as { catches: Catch[] }; -const now = Date.now(); -const step = 12 * 60 * 60 * 1000; -const buckets = Array.from({ length: 6 }, (_, index) => ({ index, count: 0 })); -for (const item of catches) { - const timestamp = new Date(item.caught_at ?? item.reported_at).getTime(); - const age = Math.floor((now - timestamp) / step); - if (age >= 0 && age < buckets.length) buckets[buckets.length - 1 - age].count += 1; -} +const { buckets } = Astro.props as { buckets: { start: string; end: string; count: number }[] }; const max = Math.max(1, ...buckets.map(bucket => bucket.count)); ---
-
Последние 72 часа

Леска активности

По показанным ниже уловам · шаг 12 часов

-
diff --git a/apps/web/src/components/CatchList.astro b/apps/web/src/components/CatchList.astro index 3203a8d..e7dd242 100644 --- a/apps/web/src/components/CatchList.astro +++ b/apps/web/src/components/CatchList.astro @@ -8,7 +8,7 @@ const { catches } = Astro.props as { catches: Catch[] }; const timestamp = item.caught_at ?? item.reported_at; return
{item.fish}{item.bait ?? "Приманка не указана"}
-
{kg(item.weight_g)}{item.player_name ?? "Анонимно"}
+
{kg(item.weight_g)}{item.player_name ?? "Анонимно"}{item.caught_at ? "Время улова" : "Получено · время улова неизвестно"}
; })} diff --git a/apps/web/src/components/DataPassport.astro b/apps/web/src/components/DataPassport.astro index b4844db..192803f 100644 --- a/apps/web/src/components/DataPassport.astro +++ b/apps/web/src/components/DataPassport.astro @@ -1,10 +1,10 @@ --- import SourceBadge from "./SourceBadge.astro"; import { ago } from "../lib/api"; -type Props = { sources: string[]; sourceUrl?: string | null; observedAt: string; completeness: number; confidence?: number | null; status?: "verified" | "unverified" | "incomplete" }; -const { sources, sourceUrl, observedAt, completeness, confidence = null, status = "verified" } = Astro.props; +type Props = { sources: string[]; sourceUrl?: string | null; observedAt: string; completeness?: number | null; confidence?: number | null; status?: "verified" | "unverified" | "incomplete" }; +const { sources, sourceUrl, observedAt, completeness = null, confidence = null, status = "verified" } = Astro.props; const statusLabels = { verified: "Учтено", unverified: "Ждёт проверки", incomplete: "Неполные данные" }; -const completenessLabel = completeness >= 100 ? "Полные" : `${Math.max(0, completeness)}% полей`; +const completenessLabel = completeness == null ? "Не рассчитана" : `${Math.min(100, Math.max(0, completeness))}% полей`; ---
Паспорт данных{statusLabels[status]}
diff --git a/apps/web/src/components/SignalFeed.astro b/apps/web/src/components/SignalFeed.astro index bc75f11..c26e0d6 100644 --- a/apps/web/src/components/SignalFeed.astro +++ b/apps/web/src/components/SignalFeed.astro @@ -3,7 +3,8 @@ import SourceBadge from "./SourceBadge.astro"; import { ago, kg, type PublicObservation } from "../lib/api"; const { signals } = Astro.props as { signals: PublicObservation[] }; const groups = [...signals.reduce((map, signal) => { - const key = [signal.fish_name, signal.waterbody_name, signal.x, signal.y, signal.weight_g].join("|").toLocaleLowerCase("ru"); + // Similar fields do not establish that two sources describe the same catch. + const key = JSON.stringify([signal.source_system, signal.id]); const current = map.get(key); if (!current) map.set(key, { ...signal, observations: [signal] }); else { diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index c7467b3..6e6dbad 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -20,8 +20,12 @@ export { activityLevel, ago, kg, plural } from "./presentation"; const base = process.env.API_INTERNAL_URL || import.meta.env.API_INTERNAL_URL || "http://localhost:8000"; +export class ApiError extends Error { + constructor(public status: number) { super(`API ${status}`); } +} + export async function api(path: string): Promise { const response = await fetch(`${base}${path}`); - if (!response.ok) throw new Error(`API ${response.status}`); + if (!response.ok) throw new ApiError(response.status); return response.json() as Promise; } diff --git a/apps/web/src/pages/admin/external-sources.astro b/apps/web/src/pages/admin/external-sources.astro index 82f0e91..3bd2302 100644 --- a/apps/web/src/pages/admin/external-sources.astro +++ b/apps/web/src/pages/admin/external-sources.astro @@ -25,9 +25,21 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; const rows = await json(`${root.dataset.apiUrl}/api/v1/admin/external-observations?limit=200`, {headers:{Authorization:`Bearer ${token}`}}); const pending = rows.filter((row: Record) => !["published", "rejected"].includes(String(row.status))); if (!pending.length) { list.innerHTML = '

Очередь пуста

Все внешние записи обработаны.

'; return; } - list.innerHTML = pending.map((row: Record) => { const complete = row.x != null && row.y != null && row.weight_g != null; return `
${esc(row.source_system)} · ${esc(row.status)}

${esc(row.fish_name)}

${esc(row.waterbody_name)}

Координаты
${row.x == null || row.y == null ? "нет" : `${esc(row.x)}:${esc(row.y)}`}
Вес
${row.weight_g == null ? "нет" : `${esc(row.weight_g)} г`}
ID источника
${esc(row.source_external_id)}
Состояние
${complete ? "полная запись" : "неполная"}
Открыть первоисточник
`; }).join(""); + list.innerHTML = pending.map((row: Record) => { const complete = row.x != null && row.y != null && row.weight_g != null; return `
${esc(row.source_system)} · ${esc(row.status)}

${esc(row.fish_name)}

${esc(row.waterbody_name)}

Координаты
${row.x == null || row.y == null ? "нет" : `${esc(row.x)}:${esc(row.y)}`}
Вес
${row.weight_g == null ? "нет" : `${esc(row.weight_g)} г`}
ID источника
${esc(row.source_external_id)}
Состояние
${complete ? "полная запись" : "неполная"}
Открыть первоисточник

`; }).join(""); } login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } }); - list?.addEventListener("click", async event => { const button = (event.target as HTMLElement).closest("button"); const card = button?.closest("[data-observation-id]"); if (!button || !card || !root) return; button.disabled = true; const base = `${root.dataset.apiUrl}/api/v1/admin/external-observations/${card.dataset.observationId}`; const headers = {Authorization:`Bearer ${token}`,"Content-Type":"application/json"}; try { if (button.hasAttribute("data-map")) { const fish_slug = card.querySelector('[name="fish"]')?.value; const waterbody_slug = card.querySelector('[name="waterbody"]')?.value; if (!fish_slug || !waterbody_slug) throw new Error("Выберите рыбу и водоём."); await json(`${base}/mapping`, {method:"PATCH",headers,body:JSON.stringify({fish_slug,waterbody_slug,note:card.querySelector('[name="note"]')?.value || null})}); } else if (button.hasAttribute("data-publish")) { await json(`${base}/publish`, {method:"POST",headers}); } else if (button.hasAttribute("data-reject")) { const reason = card.querySelector('[name="note"]')?.value.trim(); if (!reason) throw new Error("Укажите причину отклонения."); await json(`${base}/reject`, {method:"PATCH",headers,body:JSON.stringify({reason})}); } await loadQueue(); } catch (cause) { button.disabled = false; fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); } }); + list?.addEventListener("click", async event => { const button = (event.target as HTMLElement).closest("button"); const card = button?.closest("[data-observation-id]"); if (!button || !card || !root) return; button.disabled = true; const base = `${root.dataset.apiUrl}/api/v1/admin/external-observations/${card.dataset.observationId}`; const headers = {Authorization:`Bearer ${token}`,"Content-Type":"application/json"}; try { + if (button.hasAttribute("data-suggest")) { + const suggestion = await json(`${base}/alias-suggestions`, {headers}); + const fish = fishes.find(item => item.slug === suggestion.fish_slug); + const water = waters.find(item => item.slug === suggestion.waterbody_slug); + const message = card.querySelector("[data-alias-message]"); + if (message) message.textContent = fish || water + ? `Ранее подтверждено: рыба — ${fish?.name_ru ?? "нет соответствия"}, водоём — ${water?.name_ru ?? "нет соответствия"}. Проверьте и выберите значения перед сопоставлением.` + : "Для этого источника подтверждённых соответствий пока нет."; + button.disabled = false; + return; + } + if (button.hasAttribute("data-map")) { const fish_slug = card.querySelector('[name="fish"]')?.value; const waterbody_slug = card.querySelector('[name="waterbody"]')?.value; if (!fish_slug || !waterbody_slug) throw new Error("Выберите рыбу и водоём."); await json(`${base}/mapping`, {method:"PATCH",headers,body:JSON.stringify({fish_slug,waterbody_slug,note:card.querySelector('[name="note"]')?.value || null})}); } else if (button.hasAttribute("data-publish")) { await json(`${base}/publish`, {method:"POST",headers}); } else if (button.hasAttribute("data-reject")) { const reason = card.querySelector('[name="note"]')?.value.trim(); if (!reason) throw new Error("Укажите причину отклонения."); await json(`${base}/reject`, {method:"PATCH",headers,body:JSON.stringify({reason})}); } await loadQueue(); } catch (cause) { button.disabled = false; fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); } }); diff --git a/apps/web/src/pages/sitemap.xml.ts b/apps/web/src/pages/sitemap.xml.ts index 39732db..ddf35bc 100644 --- a/apps/web/src/pages/sitemap.xml.ts +++ b/apps/web/src/pages/sitemap.xml.ts @@ -1,23 +1,34 @@ import type { APIRoute } from "astro"; -import { api, spotPath, type Activity, type DictionaryItem } from "../lib/api"; +import { api, type DictionaryItem } from "../lib/api"; -const escapeXml = (value: string) => value.replace(/[<>&'\"]/g, character => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ })[character] ?? character); +const escapeXml = (value: string) => value.replace(/[<>&'"]/g, c => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ })[c] ?? c); +let lastGood: { origin: string; xml: string; at: number } | undefined; export const GET: APIRoute = async ({ site }) => { - const origin = site?.origin ?? import.meta.env.PUBLIC_SITE_URL ?? "https://rf4spotter.ru"; - const paths = new Set(["/", "/records", "/report", "/status", "/rules", "/privacy"]); + const origin = site?.origin ?? "https://rf4spotter.ru"; + const output = (xml: string) => new Response(xml, { headers: { "Content-Type": "application/xml; charset=utf-8", "Cache-Control": "public, max-age=1800" } }); + if (lastGood?.origin === origin && Date.now() - lastGood.at < 1800000) return output(lastGood.xml); try { - const [activity, fishes, waters] = await Promise.all([api("/api/v1/activity?hours=72&limit=100"), api("/api/v1/fishes?limit=500"), api("/api/v1/waterbodies?limit=500")]); - paths.add("/fish"); paths.add("/waterbodies"); - fishes.forEach(item => paths.add(`/fish/${item.slug}`)); - waters.forEach(item => paths.add(`/waterbodies/${item.slug}`)); - activity.forEach(item => paths.add(spotPath(item))); - activity.forEach(item => paths.add(`/waterbodies/${item.waterbody_slug}/${item.fish_slug}`)); + const paths = new Set(["/", "/records", "/report", "/status", "/rules", "/privacy", "/fish", "/waterbodies"]); + for (const [endpoint, prefix] of [["fishes", "fish"], ["waterbodies", "waterbodies"]]) { + for (let offset = 0; ; offset += 500) { + const rows = await api(`/api/v1/${endpoint}?limit=500&offset=${offset}`); + rows.forEach(row => paths.add(`/${prefix}/${row.slug}`)); + if (paths.size > 49000) throw new Error("Sitemap index required"); + if (rows.length < 500) break; + } + } + for (let offset = 0; ; offset += 500) { + const rows = await api(`/api/v1/public-spot-pages?limit=500&offset=${offset}`); + rows.forEach(path => paths.add(path)); + if (paths.size > 49000) throw new Error("Sitemap index required"); + if (rows.length < 1000) break; + } + const xml = `${[...paths].map(path => `${escapeXml(new URL(path, origin).toString())}`).join("")}`; + lastGood = { origin, xml, at: Date.now() }; + return output(xml); } catch { - // A temporary API outage must not make the static part of the sitemap unavailable. + if (lastGood?.origin === origin) return output(lastGood.xml); + return new Response("Sitemap temporarily unavailable", { status: 503, headers: { "Retry-After": "60", "Cache-Control": "no-store" } }); } - const urls = [...paths].map(path => `${escapeXml(new URL(path, origin).toString())}`).join(""); - return new Response(`${urls}`, { - headers: { "Content-Type": "application/xml; charset=utf-8", "Cache-Control": "public, max-age=1800" }, - }); }; diff --git a/apps/web/src/pages/spots/[id].astro b/apps/web/src/pages/spots/[id].astro index db45f7e..28ed22a 100644 --- a/apps/web/src/pages/spots/[id].astro +++ b/apps/web/src/pages/spots/[id].astro @@ -4,9 +4,10 @@ import SourceBadge from "../../components/SourceBadge.astro"; import CoordinateRadar from "../../components/CoordinateRadar.astro"; import ActivityTimeline from "../../components/ActivityTimeline.astro"; import CatchList from "../../components/CatchList.astro"; -import { activityLevel, api, plural, type Activity, type Catch, type Spot } from "../../lib/api"; +import { activityLevel, api, ApiError, plural, type Activity, type Catch, type Spot } from "../../lib/api"; const { id } = Astro.params; let spot: Spot | null = null, catches: Catch[] = [], activity: Activity | null = null, unavailable = false; +let timeline: { start: string; end: string; count: number }[] = []; try { const readable = id?.match(/^(.+)-(-?\d+)x(-?\d+)$/); const spotResult = readable @@ -15,7 +16,12 @@ try { if (!readable) return Astro.redirect(`/spots/${spotResult.waterbody_slug}-${spotResult.x}x${spotResult.y}`, 301); const [catchResult, activityRows] = await Promise.all([api(`/api/v1/spots/${spotResult.id}/catches`), api("/api/v1/activity?hours=24&limit=100")]); spot = spotResult; catches = catchResult; activity = activityRows.find(item => item.spot_id === spotResult.id) ?? null; -} catch { unavailable = true; } + timeline = await api(`/api/v1/spots/${spotResult.id}/timeline`); +} catch (error) { + unavailable = true; + Astro.response.status = error instanceof ApiError && [404, 422].includes(error.status) ? 404 : 503; + if (Astro.response.status === 503) Astro.response.headers.set("Retry-After", "60"); +} const level = activity ? activityLevel(activity.activity_score) : null; const spotDescription = spot ? `Свежие уловы и активность на точке ${spot.x}:${spot.y}, ${spot.waterbody}: рыба, вес, приманки и источники данных.` : "Данные точки ловли Russian Fishing 4."; const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: [ @@ -28,7 +34,7 @@ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "Breadcr {unavailable || !spot ?

Точка недоступна

API не ответил или такой точки нет.

: <>
{spot.waterbody}

Точка {spot.x}:{spot.y}

{spot.description}

{spot.catches_24h}за 24 часа
{spot.catches_3d}за 3 дня
{spot.catches_7d}за 7 дней
- +

Последние уловы

diff --git a/docs/AUDIT_FIXES.md b/docs/AUDIT_FIXES.md new file mode 100644 index 0000000..e06f18a --- /dev/null +++ b/docs/AUDIT_FIXES.md @@ -0,0 +1,28 @@ +# Исправления аудита 7 сентября 2026 + +## Исправлено в рабочей версии + +- [x] Убрать выдуманный процент полноты, зависящий от числа уловов. +- [x] Различать время улова и поступления; показывать точное время UTC. +- [x] Объединять только повтор одного ID источника, а не похожие события. +- [x] Ограничить кэш 128 ключами и защитить запись от устаревшего поколения. +- [x] Запретить браузерное кэширование активности; серверный TTL сохраняется. +- [x] Перенести фильтрацию активности в SQL. +- [x] Строить шкалу по полной выборке одобренных записей, с явными 12-часовыми интервалами по reported_at. +- [x] Возвращать 404 для отсутствующей точки и 503 для недоступности API. +- [x] Захватывать блокировку scheduler до чтения cooldown и фиксировать резервирование до HTTP. +- [x] Выполнять rollback перед сохранением ошибки scheduler. + +## Остаётся + +- [ ] Единый лимит по сайту для CLI и scheduler, включая неуспешные попытки. +- [ ] Межпроцессная инвалидация: сейчас фоновая публикация видна после TTL. +- [x] Изменённая публикация снимается с активности и отправляется на ручное сопоставление. Подтверждение обновляет прежний CatchReport без дубликата; старый снимок сохраняется до подтверждения. Изменения видны в API после TTL кэша. +- [ ] Обнаружение удалённых оригиналов и долговременная история всех редакций источника. +- [x] Sitemap постоянных страниц с пагинацией, без ограничения активности 72 часами. Последняя успешная версия хранится в памяти процесса; после перезапуска при недоступном API возвращается 503. +- [x] Подключить API подсказок алиасов к административному UI: кнопка показывает подтверждённые русские названия; выбор и сохранение остаются отдельными действиями. +- [ ] Оптимизировать изображения и production-зависимости. +- [ ] Визуальная проверка 320/390/768/1280 px, исправление карточек и пустых состояний. +- [ ] Согласовать остальные формулировки README/roadmap с фактическим поведением. + +Существовавшие перед этим пакетом незакоммиченные изменения кэша и карточек сохранены. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 53b5b92..5fb4c2e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,5 +1,7 @@ # План работ RF4 Spotter +Приоритет после аудита: [пакет исправлений и оставшиеся задачи](AUDIT_FIXES.md). Ранее закрытые пункты не означают, что замечания аудита устранены. + Этот файл — рабочий источник правды по развитию проекта. После завершения задачи её чекбокс меняется с `[ ]` на `[x]`, рядом добавляется ссылка на коммит или короткое подтверждение проверки. Новые задачи добавляются в соответствующий этап, а не хранятся только в переписке. Последняя сверка плана со спецификацией, кодом и UI/UX-аудитом: 7 сентября 2026 года.