diff --git a/README.md b/README.md index f3c00c7..eefc450 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо Все пять community-парсеров подключены к отдельному scheduler-процессу. Состояние запусков и ошибок хранится в PostgreSQL, параллельный запуск одного источника блокируется, минимальный интервал жёстко ограничен 1800 секундами. Локально процесс включается профилем `docker compose --profile scheduler up -d`; detail-URL RF4MAP/RF4 Posts задаются переменными окружения. +После повторных ошибок scheduler увеличивает паузу экспоненциально до 24 часов и возвращается к 30 минутам после успеха. Публичная страница `/status` показывает свежесть и состояние источников без URL запросов, внутренних ошибок и другой диагностической информации. + Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md). Production-контур для домена `rf4spotter.ru`, TLS, секреты, backup/restore и команды первого запуска описаны в [`deploy/README.md`](deploy/README.md). Он использует отдельный `compose.production.yaml`; локальный `compose.yaml` остаётся средой разработки. Production seed добавляет только справочники — демонстрационные уловы отключены. Изолированные проверки `deploy/test-production-bootstrap.sh` и `deploy/test-backup-restore.sh` подтверждают старт с пустых volumes и восстановление данных. diff --git a/apps/api/app/community_scheduler.py b/apps/api/app/community_scheduler.py index cd9dbdf..090d32b 100644 --- a/apps/api/app/community_scheduler.py +++ b/apps/api/app/community_scheduler.py @@ -16,6 +16,15 @@ from .logging_config import configure_logging from .models import CommunityImportRun, DataSource logger = logging.getLogger("rf4.community_scheduler") +MAX_BACKOFF_SECONDS = 24 * 60 * 60 + +def retry_delay(statuses: list[str]) -> int: + failures = 0 + for status in statuses: + if status != "failed": + break + failures += 1 + return min(settings.community_import_interval_seconds * (2 ** max(0, failures - 1)), MAX_BACKOFF_SECONDS) def configured_sources(): return { @@ -33,8 +42,10 @@ 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 - latest = session.scalar(select(CommunityImportRun.started_at).where(CommunityImportRun.source_system == source_system).order_by(CommunityImportRun.started_at.desc()).limit(1)) - if latest and (latest if latest.tzinfo else latest.replace(tzinfo=timezone.utc)) > current - timedelta(seconds=settings.community_import_interval_seconds): + 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 diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 0da4a98..0033416 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -24,9 +24,9 @@ from .config import settings from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation from .importer import ImportAlreadyRunning, ImportSourceError, import_records, normalize from .logging_config import configure_logging -from .models import Bait, BaitKind, CatchReport, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody +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, SpotOut, WaterbodyOut +from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, 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 @@ -209,6 +209,30 @@ def community_observations( return result +@app.get("/api/v1/source-status", response_model=list[SourceStatusOut]) +def source_status(db: Db) -> list[SourceStatusOut]: + now = datetime.now(timezone.utc) + result = [] + for source in db.scalars(select(DataSource).order_by(DataSource.name)): + runs = list(db.scalars(select(CommunityImportRun).where(CommunityImportRun.source_system == source.key).order_by(CommunityImportRun.started_at.desc()).limit(20))) + latest = runs[0] if runs else None + success = next((run for run in runs if run.status == "success"), None) + if not source.enabled: + state = "disabled" + elif latest is None: + state = "waiting" + elif latest.status == "failed": + state = "source_changed" if "CommunityParseError" in (latest.error_summary or "") else "temporarily_limited" + elif _aware(latest.started_at) < now - timedelta(seconds=settings.community_import_interval_seconds * 2): + state = "stale" + else: + state = "healthy" + result.append(SourceStatusOut(source_system=source.key, name=source.name, status=state, + last_started_at=latest.started_at if latest else None, last_success_at=success.started_at if success else None, + observations=db.scalar(select(func.count()).select_from(ExternalObservation).where(ExternalObservation.source_system == source.key)) or 0)) + return result + + @app.get("/api/v1/records", response_model=list[OfficialRecordOut]) def records( db: Db, fish: str | None = None, waterbody: str | None = None, diff --git a/apps/api/app/schemas.py b/apps/api/app/schemas.py index bc20fb9..33414c8 100644 --- a/apps/api/app/schemas.py +++ b/apps/api/app/schemas.py @@ -221,3 +221,12 @@ class ExternalObservationPublished(BaseModel): observation_id: UUID catch_report_id: UUID status: str + + +class SourceStatusOut(BaseModel): + source_system: str + name: str + status: str + last_started_at: datetime | None + last_success_at: datetime | None + observations: int diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index 65119e4..2d1a91a 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -12,7 +12,7 @@ from app.database import Base, get_session from app.community_importer import stage_observations from app.importer import ImportAlreadyRunning from app.main import app -from app.models import Bait, BaitKind, CatchReport, ExternalEntityAlias, ExternalObservation, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody +from app.models import Bait, BaitKind, CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) @@ -66,6 +66,17 @@ def test_list_pagination_and_filter_validation() -> None: assert client.get("/api/v1/admin/catch-reports?limit=101", headers=headers).status_code == 422 +def test_public_source_status_hides_internal_details() -> None: + with Session(engine) as db: + if db.get(DataSource, "rf4db") is None: + db.add(DataSource(key="rf4db", name="RF4DB", base_url="https://rf4db.com", default_confidence=70, enabled=True)) + db.commit() + response = client.get("/api/v1/source-status") + assert response.status_code == 200 + assert response.json() + assert all("error_summary" not in item and "source_url" not in item for item in response.json()) + + def test_liveness_does_not_probe_dependencies() -> None: response = client.get("/health?token=must-not-be-logged") assert response.json() == {"status": "ok"} diff --git a/apps/api/tests/test_community_scheduler.py b/apps/api/tests/test_community_scheduler.py index 49b8220..7326ad1 100644 --- a/apps/api/tests/test_community_scheduler.py +++ b/apps/api/tests/test_community_scheduler.py @@ -1,7 +1,7 @@ import pytest from pydantic import ValidationError -from app.community_scheduler import configured_sources +from app.community_scheduler import MAX_BACKOFF_SECONDS, configured_sources, retry_delay from app.config import Settings @@ -12,3 +12,10 @@ def test_all_authorized_sources_are_scheduled() -> None: def test_community_interval_cannot_be_less_than_30_minutes() -> None: with pytest.raises(ValidationError): Settings(community_import_interval_seconds=1799) + + +def test_failed_runs_back_off_but_success_resets_delay() -> None: + assert retry_delay(["failed"]) == 1800 + assert retry_delay(["failed", "failed", "failed"]) == 7200 + assert retry_delay(["failed"] * 20) == MAX_BACKOFF_SECONDS + assert retry_delay(["success", "failed"]) == 1800 diff --git a/apps/web/src/layouts/Layout.astro b/apps/web/src/layouts/Layout.astro index 6adc139..4e98588 100644 --- a/apps/web/src/layouts/Layout.astro +++ b/apps/web/src/layouts/Layout.astro @@ -1,6 +1,7 @@ --- import "../styles/global.css"; import "../styles/catalog.css"; +import "../styles/source-status.css"; import FishingIcon from "../components/FishingIcon.astro"; const { title = "RF4 Spotter — свежие точки клёва Russian Fishing 4", @@ -56,6 +57,6 @@ const jsonLd = JSON.stringify({

Свежие данные и честная оценка

- + diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index a21f7d9..c7467b3 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -12,6 +12,7 @@ export type DictionaryItem = { id: string; slug: string; name_ru: string }; export type OfficialRecord = { id: string; fish: string; weight_g: number; waterbody: string; bait: string | null; player_name: string | null; record_date: string | null; category: string | null; region: string | null; source_url: string | null; source_system: string }; export type PublicObservation = { id: string; source_system: string; source_name: string; source_url: string; fish_name: string; waterbody_name: string; x: number | null; y: number | null; weight_g: number | null; last_seen_at: string; missing_fields: string[]; quality: "incomplete" | "unverified" }; export type ImportRun = { id: string; started_at: string; finished_at: string | null; status: string; source_url: string; rows_seen: number; rows_created: number; rows_updated: number; error_summary: string | null }; +export type SourceStatus = { source_system: string; name: string; status: "healthy" | "stale" | "temporarily_limited" | "source_changed" | "waiting" | "disabled"; last_started_at: string | null; last_success_at: string | null; observations: number }; export const spotPath = (item: Pick) => `/spots/${item.waterbody_slug}-${item.x}x${item.y}`; diff --git a/apps/web/src/pages/sitemap.xml.ts b/apps/web/src/pages/sitemap.xml.ts index 53befdc..39732db 100644 --- a/apps/web/src/pages/sitemap.xml.ts +++ b/apps/web/src/pages/sitemap.xml.ts @@ -5,7 +5,7 @@ const escapeXml = (value: string) => value.replace(/[<>&'\"]/g, character => ({ 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", "/rules", "/privacy"]); + const paths = new Set(["/", "/records", "/report", "/status", "/rules", "/privacy"]); 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"); diff --git a/apps/web/src/pages/status.astro b/apps/web/src/pages/status.astro new file mode 100644 index 0000000..c1881bd --- /dev/null +++ b/apps/web/src/pages/status.astro @@ -0,0 +1,12 @@ +--- +import Layout from "../layouts/Layout.astro"; +import SourceBadge from "../components/SourceBadge.astro"; +import { ago, api, plural, type SourceStatus } from "../lib/api"; +let sources: SourceStatus[] = [], unavailable = false; +try { sources = await api("/api/v1/source-status"); } catch { unavailable = true; } +const labels = { healthy:"Актуален", stale:"Данные устарели", temporarily_limited:"Временная пауза", source_changed:"Источник изменился", waiting:"Ожидает запуска", disabled:"Выключен" }; +--- + +
Прозрачность данных

Статус источников

Пауза или изменение страницы не удаляют уже опубликованные данные.

+ {unavailable ?

Статус временно недоступен

:
{sources.map(source =>
{labels[source.status]}

{source.name}

{source.observations} {plural(source.observations,["наблюдение","наблюдения","наблюдений"])}

{source.last_success_at ? `Последнее успешное обновление ${ago(source.last_success_at)}` : "Успешных запусков пока нет"}

)}
} +
diff --git a/apps/web/src/styles/source-status.css b/apps/web/src/styles/source-status.css new file mode 100644 index 0000000..6cb217a --- /dev/null +++ b/apps/web/src/styles/source-status.css @@ -0,0 +1 @@ +.source-health-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:13px;padding:34px 0 100px}.source-health-grid article{padding:21px;border:1px solid #d3ddd5;border-radius:14px;background:#fff}.source-health-grid article>div{display:flex;justify-content:space-between;gap:8px}.source-health-grid h2{font:400 23px Georgia,serif;margin:18px 0 6px}.source-health-grid strong{font-size:13px}.source-health-grid p{color:#526662;font-size:12px}.health-state{display:flex;align-items:center;gap:6px;font-size:10px;font-weight:750;color:#526662}.health-state i{width:8px;height:8px;border-radius:50%;background:#c59a36}.source-health-grid [data-status="healthy"] .health-state i{background:#79aa36}.source-health-grid [data-status="source_changed"] .health-state i{background:#bc5748}.source-health-grid [data-status="disabled"]{opacity:.65}@media(max-width:900px){.source-health-grid{grid-template-columns:repeat(2,1fr)}}@media(max-width:620px){.source-health-grid{grid-template-columns:1fr}} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 619ffb1..fb5954e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -136,8 +136,8 @@ ### Производительность, данные и эксплуатация - [x] Добавить scheduler всех пяти разрешённых community-парсеров с устойчивым cooldown ≥30 минут, PostgreSQL lock, журналом запусков и opt-in локальным профилем; backoff после повторных ошибок остаётся отдельным улучшением (7 сентября 2026). -- [ ] Добавить экспоненциальный backoff community scheduler после повторных ошибок, не уменьшая базовый интервал 30 минут. -- [ ] Показывать публичное безопасное состояние источников: актуален, устарел, временно ограничен или изменился DOM. +- [x] Добавить экспоненциальный backoff community scheduler после повторных ошибок от 30 минут до 24 часов со сбросом после успеха (7 сентября 2026). +- [x] Показывать на `/status` безопасное состояние источников: актуален, устарел, временно ограничен, изменился DOM, ожидает запуска или выключен (7 сентября 2026). - [ ] Добавить короткое серверное кэширование публичных GET API и проверить корректную инвалидацию после публикации. - [ ] Настроить долгий immutable cache для хешированных assets и разумный cache для изображений/favicon. - [ ] Добавить пагинацию или «Показать ещё» для публичной ленты полевых сигналов. @@ -146,7 +146,7 @@ - [ ] Контролировать рост PostgreSQL и MinIO с порогами предупреждений. - [ ] Добавить фоновую проверку битых исходных ссылок с соблюдением лимитов источников. - [ ] Показывать commit SHA/версию в readiness и административной диагностике. -- [ ] Добавить публичную `/status` без внутренних адресов, секретов и лишних деталей. +- [x] Добавить публичную `/status` без внутренних адресов, секретов и текстов ошибок (7 сентября 2026). - [ ] После появления сервера подключить privacy-friendly аналитику без cookies либо собственные агрегированные счётчики. - [ ] Зафиксировать нагрузочный бюджет и проверить p95 публичных API на целевом сервере.