Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b2d7e2e1c | ||
|
|
214b328cd2 | ||
|
|
24ee01b9c3 |
@@ -16,6 +16,7 @@ OFFICIAL_RECORDS_CATEGORY=records
|
||||
OFFICIAL_IMPORT_REQUIRED=false
|
||||
IMPORT_INTERVAL_SECONDS=3600
|
||||
COMMUNITY_IMPORT_INTERVAL_SECONDS=1800
|
||||
PUBLIC_CACHE_SECONDS=20
|
||||
RF4MAP_POINT_URL=https://rf4map.ru/points/275
|
||||
RF4POSTS_SPOT_URL=https://rf4-posts.com/ru/spots/d0c6d9c6-4ebf-49a7-98a8-9a562553a8ee
|
||||
RATE_LIMIT_SECRET=change-rate-limit-secret
|
||||
|
||||
@@ -10,6 +10,7 @@ POSTGRES_PASSWORD=replace-with-long-random-value
|
||||
DATABASE_URL=postgresql+psycopg://rf4:replace-with-url-encoded-password@db:5432/rf4_spotter
|
||||
APP_VERSION=0.1.0
|
||||
APP_REVISION=replace-with-git-commit-sha
|
||||
PUBLIC_CACHE_SECONDS=20
|
||||
|
||||
ADMIN_TOKEN=replace-with-at-least-32-random-characters
|
||||
RATE_LIMIT_SECRET=replace-with-at-least-32-random-characters
|
||||
|
||||
@@ -8,13 +8,13 @@ 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. Полные записи с ранее подтверждёнными алиасами источника публикуются автоматически; новые соответствия и неполные записи остаются на ручной проверке. Admin API предлагает точные ранее подтверждённые алиасы отдельно от mapping-действия и запрещает молча переназначать alias другой сущности. Для разрешённых community-источников действует интервал не менее 30 минут на источник. Открытая альфа не использует продуктовый allowlist: интерфейс показывает весь корректно загруженный разрешённый каталог, сохраняя требования полноты и модерации.
|
||||
|
||||
На сайте у каждой записи отображается источник, а у агрегированной активности — все вошедшие в расчёт источники. Неполные community-наблюдения публикуются сразу в отдельной ленте «Полевые сигналы» с предупреждением и перечнем отсутствующих полей; до подтверждения полноты они не влияют на индекс клёва. Лента раскрывается серверной кнопкой «Показать ещё», сохраняет выбранные фильтры и ограничена 48 сигналами на страницу. Совпадающие рыба, водоём, координаты и вес объединяются визуально, при этом карточка сохраняет все уникальные ссылки на исходные наблюдения. 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-часовым шагом. Каждый улов показывает источник, относительную свежесть и точную дату в подсказке. Карточки активности и каталог дополнены лёгкими SVG-силуэтами рыб без внешних графических зависимостей. Пустые и аварийные состояния используют собственную CSS-иллюстрацию поплавка; анимация учитывает системное ограничение движения.
|
||||
|
||||
Все пять community-парсеров подключены к отдельному scheduler-процессу. Состояние запусков и ошибок хранится в PostgreSQL, параллельный запуск одного источника блокируется, минимальный интервал жёстко ограничен 1800 секундами. Локально процесс включается профилем `docker compose --profile scheduler up -d`; detail-URL RF4MAP/RF4 Posts задаются переменными окружения.
|
||||
|
||||
@@ -88,7 +88,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`. Защищённый `/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 секунд и очищается после публикации, модерации или удаления; `X-Cache` показывает `HIT`/`MISS`. Защищённый `/api/v1/admin/diagnostics` скачивает JSON только с идентификатором сборки и агрегированными счётчиками, без имён игроков, исходных URL, payload и ошибок парсеров.
|
||||
|
||||
Остановка:
|
||||
|
||||
|
||||
@@ -34,6 +34,20 @@ def map_observation(
|
||||
return observation
|
||||
|
||||
|
||||
def suggest_aliases(session: Session, observation: ExternalObservation) -> tuple[Fish | None, Waterbody | None]:
|
||||
fish_alias = session.scalar(select(ExternalEntityAlias).where(
|
||||
ExternalEntityAlias.source_system == observation.source_system,
|
||||
ExternalEntityAlias.entity_type == "fish",
|
||||
ExternalEntityAlias.external_id == (observation.fish_external_id or observation.fish_name),
|
||||
))
|
||||
waterbody_alias = session.scalar(select(ExternalEntityAlias).where(
|
||||
ExternalEntityAlias.source_system == observation.source_system,
|
||||
ExternalEntityAlias.entity_type == "waterbody",
|
||||
ExternalEntityAlias.external_id == (observation.waterbody_external_id or observation.waterbody_name),
|
||||
))
|
||||
return (fish_alias.fish if fish_alias else None, waterbody_alias.waterbody if waterbody_alias else None)
|
||||
|
||||
|
||||
def reject_observation(session: Session, observation: ExternalObservation, *, reason: str) -> ExternalObservation:
|
||||
if observation.status == "published":
|
||||
raise ExternalReviewError("published observation cannot be rejected")
|
||||
@@ -109,6 +123,8 @@ def _save_alias(
|
||||
external_id=external_id, external_name=observation.fish_name if fish else observation.waterbody_name,
|
||||
)
|
||||
session.add(alias)
|
||||
elif (fish is not None and alias.fish_id != fish.id) or (waterbody is not None and alias.waterbody_id != waterbody.id):
|
||||
raise ExternalReviewError(f"confirmed {entity_type} alias points to another entity")
|
||||
alias.fish = fish
|
||||
alias.waterbody = waterbody
|
||||
alias.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
@@ -27,6 +27,7 @@ class Settings(BaseSettings):
|
||||
retention_published_payload_days: int = Field(default=365, ge=90)
|
||||
import_interval_seconds: int = Field(default=3600, ge=3600)
|
||||
community_import_interval_seconds: int = Field(default=1800, ge=1800)
|
||||
public_cache_seconds: int = Field(default=20, ge=1, le=300)
|
||||
rf4map_point_url: str = "https://rf4map.ru/points/275"
|
||||
rf4posts_spot_url: str = "https://rf4-posts.com/ru/spots/d0c6d9c6-4ebf-49a7-98a8-9a562553a8ee"
|
||||
rate_limit_secret: str = "change-rate-limit-secret"
|
||||
|
||||
+29
-4
@@ -21,12 +21,13 @@ from sqlalchemy.orm import Session, joinedload
|
||||
from .activity import activity_rows
|
||||
from .database import get_session
|
||||
from .config import settings
|
||||
from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation
|
||||
from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation, suggest_aliases
|
||||
from .importer import ImportAlreadyRunning, ImportSourceError, import_records, normalize
|
||||
from .logging_config import configure_logging
|
||||
from .models import Bait, BaitKind, CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
|
||||
from .readiness import readiness_report
|
||||
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, PublicObservationOut, SourceStatusOut, SpotOut, WaterbodyOut
|
||||
from .public_cache import public_cache
|
||||
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, PublicObservationOut, SourceStatusOut, SpotOut, WaterbodyOut
|
||||
from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot
|
||||
|
||||
|
||||
@@ -110,7 +111,7 @@ def baits(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0,
|
||||
|
||||
@app.get("/api/v1/activity", response_model=list[ActivityOut])
|
||||
def activity(
|
||||
db: Db, hours: int = Query(24),
|
||||
db: Db, response: Response, hours: int = Query(24),
|
||||
waterbody: str | None = None, fish: str | None = None,
|
||||
method: str | None = None,
|
||||
sort: Literal["activity", "confidence", "freshness"] = "activity",
|
||||
@@ -118,6 +119,12 @@ 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}"
|
||||
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:
|
||||
response.headers["X-Cache"] = "HIT"
|
||||
return cached
|
||||
rows = activity_rows(db, hours=hours, waterbody=waterbody, fish=fish, method=method)
|
||||
keys = {
|
||||
"activity": lambda r: (r.activity_score, r.confidence_score, r.last_confirmed_at, str(r.spot_id)),
|
||||
@@ -125,7 +132,8 @@ def activity(
|
||||
"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]
|
||||
response.headers["X-Cache"] = "MISS"
|
||||
return public_cache.set(cache_key, rows[offset:offset + limit])
|
||||
|
||||
|
||||
def _spot_or_404(db: Session, spot_id: UUID) -> Spot:
|
||||
@@ -343,6 +351,20 @@ def admin_external_observations(
|
||||
return [_external_out(item) for item in items]
|
||||
|
||||
|
||||
@app.get("/api/v1/admin/external-observations/{observation_id}/alias-suggestions", response_model=ExternalAliasSuggestionOut)
|
||||
def admin_external_alias_suggestions(
|
||||
observation_id: UUID, db: Db, _: Annotated[str, Depends(_admin)],
|
||||
) -> ExternalAliasSuggestionOut:
|
||||
observation = db.get(ExternalObservation, observation_id)
|
||||
if observation is None:
|
||||
raise HTTPException(status_code=404, detail="external observation not found")
|
||||
fish, waterbody = suggest_aliases(db, observation)
|
||||
return ExternalAliasSuggestionOut(
|
||||
fish_slug=fish.slug if fish else None,
|
||||
waterbody_slug=waterbody.slug if waterbody else None,
|
||||
)
|
||||
|
||||
|
||||
@app.patch("/api/v1/admin/external-observations/{observation_id}/mapping", response_model=ExternalObservationOut)
|
||||
def admin_map_external_observation(
|
||||
observation_id: UUID, payload: ExternalObservationMapping, db: Db,
|
||||
@@ -372,6 +394,7 @@ def admin_publish_external_observation(
|
||||
report = publish_observation(db, observation)
|
||||
except ExternalReviewError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
public_cache.invalidate()
|
||||
return ExternalObservationPublished(observation_id=observation.id, catch_report_id=report.id, status=observation.status)
|
||||
|
||||
|
||||
@@ -454,6 +477,7 @@ def moderate_report(report_id: UUID, payload: ModerationUpdate, db: Db, moderato
|
||||
report.moderation_status = ModerationStatus(payload.status)
|
||||
db.add(ModerationEvent(catch_report=report, created_at=datetime.now(timezone.utc), previous_status=previous, new_status=report.moderation_status, moderator=moderator, reason=payload.reason))
|
||||
db.commit()
|
||||
public_cache.invalidate()
|
||||
return CatchReportCreated(id=report.id, moderation_status=report.moderation_status.value)
|
||||
|
||||
|
||||
@@ -476,6 +500,7 @@ def delete_report(report_id: UUID, db: Db, moderator: Annotated[str, Depends(_ad
|
||||
report.raw_payload = None
|
||||
db.add(ModerationEvent(catch_report=report, created_at=report.deleted_at, previous_status=previous, new_status=ModerationStatus.rejected, moderator=moderator, reason="user report deleted and anonymized"))
|
||||
db.commit()
|
||||
public_cache.invalidate()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from threading import Lock
|
||||
from time import monotonic
|
||||
from typing import Any
|
||||
|
||||
|
||||
class PublicResponseCache:
|
||||
def __init__(self) -> None:
|
||||
self._items: dict[tuple[Any, ...], tuple[float, Any]] = {}
|
||||
self._lock = Lock()
|
||||
|
||||
def get(self, key: tuple[Any, ...], ttl_seconds: int) -> Any | None:
|
||||
with self._lock:
|
||||
item = self._items.get(key)
|
||||
if item is None or monotonic() - item[0] >= ttl_seconds:
|
||||
self._items.pop(key, None)
|
||||
return None
|
||||
return deepcopy(item[1])
|
||||
|
||||
def set(self, key: tuple[Any, ...], value: Any) -> Any:
|
||||
with self._lock:
|
||||
self._items[key] = (monotonic(), deepcopy(value))
|
||||
return value
|
||||
|
||||
def invalidate(self) -> None:
|
||||
with self._lock:
|
||||
self._items.clear()
|
||||
|
||||
|
||||
public_cache = PublicResponseCache()
|
||||
@@ -213,6 +213,12 @@ class ExternalObservationMapping(BaseModel):
|
||||
note: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class ExternalAliasSuggestionOut(BaseModel):
|
||||
fish_slug: str | None
|
||||
waterbody_slug: str | None
|
||||
requires_confirmation: bool = True
|
||||
|
||||
|
||||
class ExternalObservationDecision(BaseModel):
|
||||
reason: str = Field(min_length=1, max_length=1000)
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ def test_list_pagination_and_filter_validation() -> None:
|
||||
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
|
||||
assert client.get("/api/v1/admin/catch-reports?limit=101", headers=headers).status_code == 422
|
||||
assert client.get("/api/v1/admin/external-observations/00000000-0000-0000-0000-000000000000/alias-suggestions").status_code == 401
|
||||
|
||||
|
||||
def test_public_source_status_hides_internal_details() -> None:
|
||||
|
||||
@@ -8,6 +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.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
|
||||
@@ -129,6 +130,31 @@ def test_auto_publication_requires_enabled_source(db: Session) -> None:
|
||||
assert item is not None and item.status == "staged" and item.catch_report is None
|
||||
|
||||
|
||||
def test_confirmed_aliases_are_suggestions_and_cannot_be_retargeted(db: Session) -> None:
|
||||
source = DataSource(key="rf4db", name="RF4DB", base_url="https://rf4db.com", default_confidence=70, enabled=True)
|
||||
pike = Fish(slug="pike", name_ru="Щука")
|
||||
perch = Fish(slug="perch", name_ru="Окунь")
|
||||
waterbody = Waterbody(slug="test-lake", name_ru="Тестовое озеро")
|
||||
db.add_all([source, pike, perch, waterbody])
|
||||
db.commit()
|
||||
stage_observations(db, [record(external_id="first")])
|
||||
first = db.scalar(select(ExternalObservation).where(ExternalObservation.source_external_id == "first"))
|
||||
assert first is not None
|
||||
map_observation(db, first, pike, waterbody)
|
||||
|
||||
stage_observations(db, [record(external_id="second")])
|
||||
second = db.scalar(select(ExternalObservation).where(ExternalObservation.source_external_id == "second"))
|
||||
assert second is not None
|
||||
suggested_fish, suggested_waterbody = suggest_aliases(db, second)
|
||||
assert (suggested_fish.slug, suggested_waterbody.slug) == ("pike", "test-lake")
|
||||
|
||||
with pytest.raises(ExternalReviewError, match="confirmed fish alias"):
|
||||
map_observation(db, second, perch, waterbody)
|
||||
db.rollback()
|
||||
alias = db.scalar(select(ExternalEntityAlias).where(ExternalEntityAlias.entity_type == "fish"))
|
||||
assert alias is not None and alias.fish_id == pike.id
|
||||
|
||||
|
||||
def test_parser_json_can_be_staged_without_losing_provenance(db: Session) -> None:
|
||||
parsed = parse_rf4db_catches(FIXTURE.read_text(encoding="utf-8"))
|
||||
payload = json.loads(json.dumps([asdict(item) for item in parsed], default=str))
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
from app.public_cache import PublicResponseCache
|
||||
|
||||
|
||||
def test_cache_copies_values_and_invalidates() -> None:
|
||||
cache = PublicResponseCache()
|
||||
original = [{"score": 10}]
|
||||
cache.set(("activity",), original)
|
||||
original[0]["score"] = 99
|
||||
assert cache.get(("activity",), 20) == [{"score": 10}]
|
||||
cache.invalidate()
|
||||
assert cache.get(("activity",), 20) is None
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
import SourceBadge from "./SourceBadge.astro";
|
||||
import { ago, kg, type Catch } from "../lib/api";
|
||||
const { catches } = Astro.props as { catches: Catch[] };
|
||||
---
|
||||
<div class="catch-list">
|
||||
{catches.map(item => {
|
||||
const timestamp = item.caught_at ?? item.reported_at;
|
||||
return <article>
|
||||
<div><strong>{item.fish}</strong><span>{item.bait ?? "Приманка не указана"}</span><SourceBadge source={item.source_system} href={item.source_url}/></div>
|
||||
<div><strong>{kg(item.weight_g)}</strong><span>{item.player_name ?? "Анонимно"}</span><time datetime={timestamp} title={new Date(timestamp).toLocaleString("ru-RU")}>{ago(timestamp)}</time></div>
|
||||
</article>;
|
||||
})}
|
||||
</div>
|
||||
<style>
|
||||
time{margin-top:7px;color:#496357;font-size:10px;font-weight:750}
|
||||
</style>
|
||||
@@ -3,7 +3,8 @@ import Layout from "../../layouts/Layout.astro";
|
||||
import SourceBadge from "../../components/SourceBadge.astro";
|
||||
import CoordinateRadar from "../../components/CoordinateRadar.astro";
|
||||
import ActivityTimeline from "../../components/ActivityTimeline.astro";
|
||||
import { activityLevel, api, kg, plural, type Activity, type Catch, type Spot } from "../../lib/api";
|
||||
import CatchList from "../../components/CatchList.astro";
|
||||
import { activityLevel, api, 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;
|
||||
try {
|
||||
@@ -28,6 +29,12 @@ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "Breadcr
|
||||
<section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p></div><CoordinateRadar x={spot.x} y={spot.y}/></section>
|
||||
<div class="periods"><div><strong>{spot.catches_24h}</strong><span>за 24 часа</span></div><div><strong>{spot.catches_3d}</strong><span>за 3 дня</span></div><div><strong>{spot.catches_7d}</strong><span>за 7 дней</span></div></div>
|
||||
<ActivityTimeline catches={catches}/>
|
||||
<section class="detail-grid"><div><div class="section-heading"><h2>Последние уловы</h2></div>{catches.length ? <div class="catch-list">{catches.map(item => <article><div><strong>{item.fish}</strong><span>{item.bait ?? "Приманка не указана"}</span><SourceBadge source={item.source_system} href={item.source_url}/></div><div><strong>{kg(item.weight_g)}</strong><span>{item.player_name ?? "Анонимно"}</span></div></article>)}</div> : <div class="state compact-state"><h2>Уловов пока нет</h2><p>Для этой точки нет одобренных наблюдений.</p></div>}</div><aside><span class="eyebrow">Оценка за 24 часа</span>{activity && level ? <><h2>{activity.activity_score} / 100</h2><strong data-activity-level={level.short}>{level.description}</strong><div class="source-strip">{activity.sources.map(source => <SourceBadge source={source}/>)}</div><p>{activity.explanation}</p><p class="note">Основано на {activity.catches} {plural(activity.catches, ["наблюдении", "наблюдениях", "наблюдениях"])} от {activity.unique_players} {plural(activity.unique_players, ["игрока", "игроков", "игроков"])}. Уверенность: {activity.confidence_score}%.</p></> : <p>За последние 24 часа данных для расчёта нет.</p>}<span class="eyebrow">Лучшие приманки</span>{spot.top_baits.length ? <ol>{spot.top_baits.map(name => <li>{name}</li>)}</ol> : <p>Недостаточно данных.</p>}<p class="note">Учитываются только одобренные наблюдения.</p></aside></section>
|
||||
<section class="detail-grid">
|
||||
<div>
|
||||
<div class="section-heading"><h2>Последние уловы</h2></div>
|
||||
{catches.length ? <CatchList catches={catches}/> : <div class="state compact-state"><h2>Уловов пока нет</h2><p>Для этой точки нет одобренных наблюдений.</p></div>}
|
||||
</div>
|
||||
<aside><span class="eyebrow">Оценка за 24 часа</span>{activity && level ? <><h2>{activity.activity_score} / 100</h2><strong data-activity-level={level.short}>{level.description}</strong><div class="source-strip">{activity.sources.map(source => <SourceBadge source={source}/>)}</div><p>{activity.explanation}</p><p class="note">Основано на {activity.catches} {plural(activity.catches, ["наблюдении", "наблюдениях", "наблюдениях"])} от {activity.unique_players} {plural(activity.unique_players, ["игрока", "игроков", "игроков"])}. Уверенность: {activity.confidence_score}%.</p></> : <p>За последние 24 часа данных для расчёта нет.</p>}<span class="eyebrow">Лучшие приманки</span>{spot.top_baits.length ? <ol>{spot.top_baits.map(name => <li>{name}</li>)}</ol> : <p>Недостаточно данных.</p>}<p class="note">Учитываются только одобренные наблюдения.</p></aside>
|
||||
</section>
|
||||
</>}
|
||||
</Layout>
|
||||
|
||||
@@ -121,6 +121,7 @@ services:
|
||||
OFFICIAL_IMPORT_REQUIRED: ${OFFICIAL_IMPORT_REQUIRED:-false}
|
||||
SEED_DEMO_DATA: "false"
|
||||
IMPORT_INTERVAL_SECONDS: ${IMPORT_INTERVAL_SECONDS:-3600}
|
||||
PUBLIC_CACHE_SECONDS: ${PUBLIC_CACHE_SECONDS:-20}
|
||||
RATE_LIMIT_SECRET: ${RATE_LIMIT_SECRET:?Set RATE_LIMIT_SECRET}
|
||||
RETENTION_SUBMISSION_DAYS: ${RETENTION_SUBMISSION_DAYS:-1}
|
||||
RETENTION_UNREVIEWED_DAYS: ${RETENTION_UNREVIEWED_DAYS:-30}
|
||||
|
||||
@@ -33,6 +33,8 @@ docker run --rm caddy:2.10.2-alpine caddy hash-password --plaintext 'ОТДЕЛ
|
||||
|
||||
Перед сборкой запишите текущий `git rev-parse --short HEAD` в `APP_REVISION` файла `.env.production`, чтобы `/ready` однозначно показывал развёрнутый commit.
|
||||
|
||||
`PUBLIC_CACHE_SECONDS` задаёт короткий in-process кэш публичного агрегата активности (по умолчанию 20 секунд). Не увеличивайте его без повторной проверки свежести после публикации.
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env.production -f compose.production.yaml config --quiet
|
||||
docker compose --env-file .env.production -f compose.production.yaml build
|
||||
|
||||
+3
-2
@@ -102,7 +102,7 @@
|
||||
- [x] Добавить staging-модель внешних наблюдений и идемпотентный импорт RF4DB/RF4-STAT без автоматического влияния на индекс (миграция `0008`, сквозной контрактный тест).
|
||||
- [x] Добавить административную очередь сопоставления staging-записей с каноническими рыбами/водоёмами и явную публикацию в `catch_report` (`/admin/external-sources`, миграция `0009`; неполные записи публиковать запрещено).
|
||||
- [ ] Найти разрешённый способ получать полные наблюдения с рыбой, водоёмом, координатами и весом из одного источника либо через подтверждённый общий ID; текущие 139 записей неполны и не публикуются.
|
||||
- [ ] Добавить безопасные подсказки алиасов по уже подтверждённым сопоставлениям без автоматической публикации и тест конфликтующих алиасов.
|
||||
- [x] Добавить защищённые подсказки алиасов по точным подтверждённым сопоставлениям без применения и запретить конфликтующую перезапись алиаса (7 сентября 2026).
|
||||
- [ ] Добавить управляемый повторный импорт staging с отчётом `created/updated/rejected`, лимитами запросов и наблюдаемым отказом при изменении DOM; регулярный запуск оставить выключенным до фиксации условий.
|
||||
- [ ] Определить процедуру повторной проверки опубликованного внешнего улова при изменении или удалении записи у источника.
|
||||
- [ ] Согласовать один добровольный канал сообщества и правила происхождения, модерации и удаления сообщений.
|
||||
@@ -124,6 +124,7 @@
|
||||
### Графические элементы и объяснение данных
|
||||
|
||||
- [x] Не показывать дублирующий sidebar лидера при единственной найденной точке (7 сентября 2026).
|
||||
- [x] Показывать у каждого улова на detail-странице источник, относительную свежесть и точную дату в подсказке (7 сентября 2026).
|
||||
- [x] Подготовить фирменный знак «крючок + поплавок + координатная сетка» и согласованный набор favicon/app icons (7 сентября 2026).
|
||||
- [x] Добавить честный координатный радар без имитации отсутствующей карты на страницу точки (7 сентября 2026).
|
||||
- [x] Добавить временную шкалу активности 72 часа с 12-часовым шагом в виде лески с поплавками (7 сентября 2026).
|
||||
@@ -140,7 +141,7 @@
|
||||
- [x] Добавить scheduler всех пяти разрешённых community-парсеров с устойчивым cooldown ≥30 минут, PostgreSQL lock, журналом запусков и opt-in локальным профилем; backoff после повторных ошибок остаётся отдельным улучшением (7 сентября 2026).
|
||||
- [x] Добавить экспоненциальный backoff community scheduler после повторных ошибок от 30 минут до 24 часов со сбросом после успеха (7 сентября 2026).
|
||||
- [x] Показывать на `/status` безопасное состояние источников: актуален, устарел, временно ограничен, изменился DOM, ожидает запуска или выключен (7 сентября 2026).
|
||||
- [ ] Добавить короткое серверное кэширование публичных GET API и проверить корректную инвалидацию после публикации.
|
||||
- [x] Добавить 20-секундный серверный кэш агрегата активности с явной инвалидацией после публикации, модерации и удаления (7 сентября 2026).
|
||||
- [x] Настроить долгий immutable cache для хешированных assets и разумный cache для изображений/favicon (7 сентября 2026).
|
||||
- [x] Добавить серверное «Показать ещё» для публичной ленты полевых сигналов с сохранением фильтров и пределом 48 записей (7 сентября 2026).
|
||||
- [x] Объединять одинаковые полевые сигналы в сюжеты без потери уникальных ссылок provenance и показывать число совпадений (7 сентября 2026).
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@
|
||||
- [x] Заменить slug-input фильтрами из справочников и добавить сброс.
|
||||
- [x] Сделать мобильную выдачу рекордов самодостаточной с подписями значений.
|
||||
- [x] Убрать CLI-команду из публичного empty state.
|
||||
- [ ] Добавить к уловам detail-страницы дату/свежесть и происхождение, если API их отдаёт.
|
||||
- [x] Добавить к уловам detail-страницы семантическую дату, относительную свежесть и происхождение (7 сентября 2026).
|
||||
- [x] Устранить дублирование карточки и sidebar на малой выборке: блок лидера показывается только при двух и более результатах (7 сентября 2026).
|
||||
|
||||
Критерий: фильтрация не требует технических терминов; каждая мобильная запись читается без заголовка таблицы; источник и свежесть понятны.
|
||||
|
||||
Reference in New Issue
Block a user