diff --git a/apps/api/app/media_catalog.py b/apps/api/app/media_catalog.py index 336a382..5652e5a 100644 --- a/apps/api/app/media_catalog.py +++ b/apps/api/app/media_catalog.py @@ -29,6 +29,17 @@ def published_assets(entity_type: str | None = None) -> list[dict]: "image_url": f"/api/v1/media/assets/{item['sha256']}", "source_system": source, "source_url": source_page, + "variants": [ + { + "role": variant.get("role"), + "format": variant.get("format"), + "width": variant.get("width"), + "height": variant.get("height"), + "url": f"/api/v1/media/assets/{variant['sha256']}", + } + for variant in item.get("derivatives", []) + if variant.get("sha256") and variant.get("local_path") + ], }) return sorted(result, key=lambda item: (str(item["entity_type"]), str(item["label"] or "").casefold(), item["id"])) @@ -44,3 +55,51 @@ def published_file(digest: str) -> tuple[Path, str] | None: if not target.is_relative_to(MEDIA_ROOT.resolve()) or not target.is_file(): return None return target, str(item["content_type"]) + + +def review_assets(entity_type: str | None = None, status: str | None = None) -> list[dict]: + manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8")) + result = [] + for item in manifest.get("assets", []): + item_status = str(item.get("status") or "") + if item_status not in {"approved", "upgrade_queued", "upgrade_stored"} or (status and item_status != status): + continue + if entity_type and item.get("entity_type") != entity_type: + continue + digest = str(item.get("sha256") or "") + if len(digest) != 64 or not item.get("local_path"): + continue + source_page = str(item.get("source_page") or "") + source = "rf4db" if "rf4db.com" in source_page else "rf4map" if "rf4map.ru" in source_page else "rf4-official" + result.append({ + "id": digest, + "status": item_status, + "entity_type": item.get("entity_type"), + "entity_key": item.get("entity_key"), + "label": item.get("label"), + "width": item.get("width"), + "height": item.get("height"), + "content_type": item.get("content_type"), + "image_url": f"/api/v1/admin/media/assets/{digest}", + "source_system": source, + "source_url": source_page, + "duplicate_of": item.get("duplicate_of"), + "derivatives": [{ + "role": variant.get("role"), "format": variant.get("format"), + "width": variant.get("width"), "height": variant.get("height"), + } for variant in item.get("derivatives", [])], + }) + return sorted(result, key=lambda item: (str(item["status"]), str(item["entity_type"]), str(item["label"] or "").casefold(), item["id"])) + + +def review_file(digest: str) -> tuple[Path, str] | None: + if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest): + return None + manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8")) + item = next((row for row in manifest.get("assets", []) if row.get("sha256") == digest and row.get("status") in {"approved", "upgrade_queued", "upgrade_stored"}), None) + if not item or not item.get("local_path"): + return None + target = (MEDIA_ROOT / item["local_path"]).resolve() + if not target.is_relative_to(MEDIA_ROOT.resolve()) or not target.is_file(): + return None + return target, str(item.get("content_type") or "application/octet-stream") diff --git a/apps/api/app/routers/admin.py b/apps/api/app/routers/admin.py index ecd4b9d..52d0d47 100644 --- a/apps/api/app/routers/admin.py +++ b/apps/api/app/routers/admin.py @@ -1,12 +1,12 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Annotated, Literal from uuid import UUID import httpx from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response -from fastapi.responses import JSONResponse +from fastapi.responses import FileResponse, JSONResponse from sqlalchemy import case, func, or_, select from sqlalchemy.orm import joinedload @@ -15,6 +15,7 @@ from ..community_review import ExternalReviewError, map_observation, publish_obs from ..config import settings from ..dependencies import Db from ..importer import ImportAlreadyRunning, ImportSourceError, import_records +from ..media_catalog import review_assets, review_file from ..models import CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Waterbody from ..public_cache import public_cache from ..schemas import AdminCatchReportOut, AdminModerationHistoryOut, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationAction, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationUpdate @@ -29,6 +30,26 @@ def _admin(request: Request, db: Db, authorization: Annotated[str | None, Header return verify_admin(request, db, authorization, settings) +@router.get("/api/v1/admin/media/catalog") +def admin_media_catalog( + _: Annotated[str, Depends(_admin)], + entity_type: str | None = Query(None, pattern="^(fish|waterbody|tackle|reference)$"), + status: str | None = Query(None, pattern="^(approved|upgrade_queued|upgrade_stored)$"), + limit: int = Query(50, ge=1, le=100), + offset: int = Query(0, ge=0), +) -> list[dict]: + return review_assets(entity_type, status)[offset:offset + limit] + + +@router.get("/api/v1/admin/media/assets/{digest}", response_class=FileResponse) +def admin_media_asset(digest: str, _: Annotated[str, Depends(_admin)]) -> FileResponse: + item = review_file(digest) + if not item: + raise HTTPException(status_code=404, detail="Media review asset not found") + path, media_type = item + return FileResponse(path, media_type=media_type, headers={"Cache-Control": "private, no-store"}) + + @router.get("/api/v1/admin/diagnostics") def admin_diagnostics(db: Db, _: Annotated[str, Depends(_admin)]) -> JSONResponse: report_counts = {status.value: count for status, count in db.execute( @@ -131,6 +152,52 @@ def admin_start_official_import(db: Db, _: Annotated[str, Depends(_admin)]) -> O raise HTTPException(status_code=502, detail=f"official records import failed: {exc}") from exc +@router.get("/api/v1/admin/source-status") +def admin_source_status(db: Db, _: Annotated[str, Depends(_admin)]) -> list[dict[str, object]]: + """Return safe operational details needed by the owner dashboard.""" + now = datetime.now(timezone.utc) + result: list[dict[str, object]] = [] + 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) + recent_failures = sum( + 1 for run in runs + if run.status == "failed" and aware(run.started_at) >= now - timedelta(hours=24) + ) + next_allowed = ( + aware(latest.started_at) + timedelta(seconds=settings.community_import_interval_seconds) + if latest else None + ) + cooldown_seconds = max(0, int((next_allowed - now).total_seconds())) if next_allowed else 0 + 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({ + "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, + "next_allowed_at": next_allowed, + "cooldown_seconds": cooldown_seconds, + "recent_failures_24h": recent_failures, + "backoff_recommended": recent_failures >= 5, + }) + return result + + def _external_out(item: ExternalObservation) -> ExternalObservationOut: allowed_payload = { key: value for key, value in (item.payload or {}).items() diff --git a/apps/api/openapi.json b/apps/api/openapi.json index ba64333..cdff32a 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -30,6 +30,17 @@ "title": "Confidence Score", "type": "integer" }, + "coordinate_precision": { + "title": "Coordinate Precision", + "type": "string" + }, + "coordinate_sources": { + "items": { + "type": "string" + }, + "title": "Coordinate Sources", + "type": "array" + }, "explanation": { "title": "Explanation", "type": "string" @@ -101,7 +112,9 @@ "activity_score", "confidence_score", "explanation", - "sources" + "sources", + "coordinate_precision", + "coordinate_sources" ], "title": "ActivityOut", "type": "object" @@ -1610,6 +1623,17 @@ "title": "Catches 7D", "type": "integer" }, + "coordinate_precision": { + "title": "Coordinate Precision", + "type": "string" + }, + "coordinate_sources": { + "items": { + "type": "string" + }, + "title": "Coordinate Sources", + "type": "array" + }, "description": { "anyOf": [ { @@ -1660,7 +1684,9 @@ "catches_24h", "catches_3d", "catches_7d", - "top_baits" + "top_baits", + "coordinate_precision", + "coordinate_sources" ], "title": "SpotOut", "type": "object" @@ -1700,6 +1726,28 @@ }, "WaterbodyOut": { "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "fish_species_count": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Fish Species Count" + }, "id": { "format": "uuid", "title": "Id", @@ -1713,6 +1761,107 @@ "title": "Slug", "type": "string" }, + "source_aliases": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Source Aliases" + }, + "source_checked_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Checked At" + }, + "source_external_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source External Id" + }, + "source_fish_species": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Source Fish Species" + }, + "source_image_urls": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Source Image Urls" + }, + "source_point_urls": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Source Point Urls" + }, + "source_system": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source System" + }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url" + }, "unlock_level": { "anyOf": [ { @@ -1729,7 +1878,17 @@ "id", "slug", "name_ru", - "unlock_level" + "unlock_level", + "fish_species_count", + "source_system", + "source_external_id", + "source_url", + "description", + "source_aliases", + "source_fish_species", + "source_image_urls", + "source_point_urls", + "source_checked_at" ], "title": "WaterbodyOut", "type": "object" @@ -2651,6 +2810,162 @@ "summary": "Admin Start Official Import" } }, + "/api/v1/admin/media/assets/{digest}": { + "get": { + "operationId": "admin_media_asset_api_v1_admin_media_assets__digest__get", + "parameters": [ + { + "in": "path", + "name": "digest", + "required": true, + "schema": { + "title": "Digest", + "type": "string" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Media Asset" + } + }, + "/api/v1/admin/media/catalog": { + "get": { + "operationId": "admin_media_catalog_api_v1_admin_media_catalog_get", + "parameters": [ + { + "in": "query", + "name": "entity_type", + "required": false, + "schema": { + "anyOf": [ + { + "pattern": "^(fish|waterbody|tackle|reference)$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Entity Type" + } + }, + { + "in": "query", + "name": "status", + "required": false, + "schema": { + "anyOf": [ + { + "pattern": "^(approved|upgrade_queued|upgrade_stored)$", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Status" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 100, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Response Admin Media Catalog Api V1 Admin Media Catalog Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Media Catalog" + } + }, "/api/v1/admin/moderation-history": { "get": { "operationId": "admin_moderation_history_api_v1_admin_moderation_history_get", @@ -2781,6 +3096,58 @@ "summary": "Admin Moderation History Export" } }, + "/api/v1/admin/source-status": { + "get": { + "description": "Return safe operational details needed by the owner dashboard.", + "operationId": "admin_source_status_api_v1_admin_source_status_get", + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Response Admin Source Status Api V1 Admin Source Status Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Admin Source Status" + } + }, "/api/v1/baits": { "get": { "operationId": "baits_api_v1_baits_get", diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index 840f91c..94e629a 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -56,6 +56,17 @@ def test_activity_filters_and_explains_score() -> None: assert payload["items"][0]["sources"] == ["manual-import"] +def test_waterbody_catalog_exposes_nullable_source_provenance() -> None: + response = client.get("/api/v1/waterbodies") + assert response.status_code == 200 + item = next(row for row in response.json() if row["slug"] == "test-lake") + assert item["source_system"] is None + assert item["source_external_id"] is None + assert item["source_url"] is None + assert item["description"] is None + assert item["source_checked_at"] is None + + def test_invalid_period_is_rejected() -> None: assert client.get("/api/v1/activity?hours=13").status_code == 422 assert client.get("/api/v1/activity?sort=unknown").status_code == 422 @@ -151,6 +162,24 @@ def test_public_source_status_hides_internal_details() -> None: assert all("error_summary" not in item and "source_url" not in item for item in response.json()) +def test_admin_source_status_requires_auth_and_exposes_safe_cooldown_fields() -> None: + assert client.get("/api/v1/admin/source-status").status_code == 401 + response = client.get("/api/v1/admin/source-status", headers={"Authorization": "Bearer change-me-in-production"}) + assert response.status_code == 200 + assert response.json() + assert all({"status", "cooldown_seconds", "recent_failures_24h", "backoff_recommended"} <= set(item) for item in response.json()) + assert all("error_summary" not in item and "base_url" not in item for item in response.json()) + + +def test_admin_media_review_requires_auth() -> None: + assert client.get("/api/v1/admin/media/catalog").status_code == 401 + response = client.get("/api/v1/admin/media/catalog?status=approved&limit=2", headers={"Authorization": "Bearer change-me-in-production"}) + assert response.status_code == 200 + assert len(response.json()) <= 2 + if response.json(): + assert {"status", "width", "height", "source_system", "source_url", "derivatives"} <= set(response.json()[0]) + + 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/web/src/layouts/Layout.astro b/apps/web/src/layouts/Layout.astro index 3f8431a..eb4cdfb 100644 --- a/apps/web/src/layouts/Layout.astro +++ b/apps/web/src/layouts/Layout.astro @@ -11,6 +11,7 @@ import "../styles/tackle-glyph.css"; import "../styles/empty-states.css"; import "../styles/alpha-banner.css"; import "../styles/signal-pagination.css"; +import "../styles/pagination.css"; import "../styles/dashboard-polish.css"; import "../styles/loading-states.css"; import "../styles/theme.css"; @@ -31,7 +32,7 @@ const theme = storedTheme === "light" || storedTheme === "dark" ? storedTheme : const siteUrl = import.meta.env.PUBLIC_SITE_URL || "https://rf4spotter.ru"; const canonical = new URL(path, siteUrl).toString(); const socialImage = new URL(image, siteUrl).toString(); -const preventIndexing = noindex || path.startsWith("/admin/"); +const preventIndexing = noindex || path === "/admin" || path.startsWith("/admin/"); const websiteJsonLd = { "@type": "WebSite", name: "RF4 Spotter", url: siteUrl, inLanguage: "ru" }; // A08: Skip structuredData on error pages (explicit errorPage prop) // Don't infer error from noindex alone — main page can have noindex on 422 diff --git a/apps/web/src/pages/admin/external-sources.astro b/apps/web/src/pages/admin/external-sources.astro index fd66a40..3e2c1c9 100644 --- a/apps/web/src/pages/admin/external-sources.astro +++ b/apps/web/src/pages/admin/external-sources.astro @@ -19,7 +19,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";

S подсказать · M сопоставить · P опубликовать карточку с фокусом

@@ -32,6 +32,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; const pages = document.querySelector("[data-pages]"); const previous = document.querySelector("[data-previous]"); const next = document.querySelector("[data-next]"); + const refresh = document.querySelector("[data-refresh]"); const pageNumber = document.querySelector("[data-page-number]"); const sessionBar = document.querySelector(".admin-session-bar"); const logout = document.querySelector("[data-admin-logout]"); @@ -48,7 +49,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (filters) filters.hidden = true; if (list) list.innerHTML = ""; pages?.setAttribute("hidden", ""); if (message) fail(message); }; const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); }; const options = (items: Record[], selected?: unknown) => items.map(item => ``).join(""); - async function json(url: string, init: RequestInit = {}) { const response = await fetch(url, init); if (response.status === 401) { endSession(); throw new Error("Неверный или истёкший административный токен."); } if (response.status === 409) { await loadQueue(); throw new Error("Запись уже изменена в другой вкладке. Очередь обновлена."); } if (!response.ok) throw new Error(`Запрос завершился ошибкой ${response.status}.`); if ((init.headers as Record | undefined)?.Authorization) keepSession(); return response.json(); } + async function json(url: string, init: RequestInit = {}) { const response = await fetch(url, init); if (response.status === 401) { endSession(); throw new Error("Неверный или истёкший административный токен."); } if (response.status === 409) { await loadQueue(); throw new Error("Запись уже изменена в другой вкладке. Очередь обновлена."); } if (response.status === 429) { endSession(); throw new Error("Слишком много попыток. Повторите позже."); } if (!response.ok) throw new Error(`Запрос завершился ошибкой ${response.status}.`); if ((init.headers as Record | undefined)?.Authorization) keepSession(); return response.json(); } async function loadQueue() { if (!root || !list) return; error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards(); if (!fishes.length || !waters.length) { const [fishRows, waterRows, sources] = await Promise.all([json(`${root.dataset.apiUrl}/api/v1/fishes`), json(`${root.dataset.apiUrl}/api/v1/waterbodies`), json(`${root.dataset.apiUrl}/api/v1/source-status`)]); fishes = fishRows; waters = waterRows; const sourceSelect = filters?.querySelector('[name="source"]'); if (sourceSelect) sourceSelect.innerHTML = '' + (sources as Record[]).map(source => ``).join(""); } @@ -89,6 +90,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; } previous?.addEventListener("click", () => changePage(-50)); next?.addEventListener("click", () => changePage(50)); + refresh?.addEventListener("click", async () => { refresh.disabled = true; try { await loadQueue(); succeed("Очередь обновлена."); } catch (cause) { fail(cause instanceof Error ? cause.message : "Не удалось обновить очередь."); } finally { refresh.disabled = false; } }); login?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; if (filters) filters.hidden = false; } catch (cause) { setLoading(false); if (list) list.innerHTML = ""; fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } }); filters?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; try { await loadQueue(); } catch (cause) { setLoading(false); fail(cause instanceof Error ? cause.message : "Ошибка фильтрации."); } }); logout?.addEventListener("click", () => endSession("Вы вышли из административной панели.")); diff --git a/apps/web/src/pages/admin/index.astro b/apps/web/src/pages/admin/index.astro index 12a5a91..be6b0b2 100644 --- a/apps/web/src/pages/admin/index.astro +++ b/apps/web/src/pages/admin/index.astro @@ -9,6 +9,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";

Токен существует только в памяти вкладки. Сессия завершится после 15 минут бездействия.

+
diff --git a/apps/web/src/pages/admin/media.astro b/apps/web/src/pages/admin/media.astro new file mode 100644 index 0000000..741e7b8 --- /dev/null +++ b/apps/web/src/pages/admin/media.astro @@ -0,0 +1,57 @@ +--- +import Layout from "../../layouts/Layout.astro"; +const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; +--- + +
ADMIN Media review

Проверка
медиа

Сравнение approved и upgrade_queued файлов перед отдельным редакционным решением.

+
+ +

Публичные файлы не переключаются из этого экрана. Токен хранится только в памяти страницы.

+ + +
+ +
+ +
diff --git a/apps/web/src/pages/admin/moderation.astro b/apps/web/src/pages/admin/moderation.astro index c3855de..2b545c4 100644 --- a/apps/web/src/pages/admin/moderation.astro +++ b/apps/web/src/pages/admin/moderation.astro @@ -9,6 +9,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";

Токен хранится только в памяти страницы и не записывается в URL или localStorage.

+

A одобрить карточку с фокусом · отклонение и удаление — только кнопками

diff --git a/deploy/Caddyfile b/deploy/Caddyfile index 449a4ac..32ad0cb 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -18,7 +18,7 @@ @manifest path /site.webmanifest header @manifest Cache-Control "public, max-age=3600" - @admin path /admin/* + @admin path /admin /admin/* handle @admin { basic_auth { {$ADMIN_BASIC_USER} {$ADMIN_BASIC_PASSWORD_HASH} diff --git a/deploy/probe-admin-auth.py b/deploy/probe-admin-auth.py index 329c641..3913f0f 100644 --- a/deploy/probe-admin-auth.py +++ b/deploy/probe-admin-auth.py @@ -29,6 +29,8 @@ def probe(path, expected, authorization=None, method="GET", data=None): assert isinstance(json.load(response), dict) +probe("/admin", 401) +probe("/admin", 200, basic) probe("/admin/moderation", 401) probe("/admin/moderation", 200, basic) probe("/admin/moderation", 401, "Bearer " + token) diff --git a/docs/PROJECT_AUDIT_2026-09-10.md b/docs/PROJECT_AUDIT_2026-09-10.md new file mode 100644 index 0000000..d265cb6 --- /dev/null +++ b/docs/PROJECT_AUDIT_2026-09-10.md @@ -0,0 +1,389 @@ +# Полный аудит проекта RF4 Spotter — 10 сентября 2026 + +База: `13e04e6`. Проверены: архитектура, backend, frontend, security, performance, testing, deployment, documentation. + +--- + +## 1. Архитектура и высокоуровневый обзор + +### Стек +| Слой | Технология | Статус | +|------|-----------|--------| +| **Proxy** | Caddy 2.10 | Production-ready, TLS завершение, Basic Auth на admin | +| **Frontend** | Astro 7 SSR (Node) | Сборка 0 errors, адаптивный дизайн | +| **Backend** | FastAPI + SQLAlchemy 2 + PostgreSQL 17 | 130 тестов, 1 skipped | +| **Хранилище** | PostgreSQL 17 + MinIO/S3 | Volumes, backup/restore | +| **CI** | Gitea Actions | Python, Astro, Compose E2E, pip-audit | +| **Scheduler** | Official import + Community scheduler | Опциональный профиль | + +### Оценка архитектуры: ✅ Хорошо (8/10) +- Чёткое разделение: Caddy → Astro SSR → FastAPI → Postgres/MinIO +- Нет точки отказа в виде единого контейнера +- Profiles в docker-compose для scheduler/importer +- Production compose отдельно от dev + +--- + +## 2. Backend (FastAPI) — детальный разбор + +### 2.1 `main.py` (~1100 строк) — 🔴 КРИТИЧЕСКАЯ ПРОБЛЕМА + +**Проблемы:** +- Все endpoints в одном файле — нарушение SRP +- `create_catch_report` — 50+ строк бизнес-логики inline +- `_check_rate_limit` и `_is_trusted_proxy` — утилиты должны быть в отдельном модуле +- `_admin`, `_spot_or_404`, `_report_source` — общие функции смешаны с endpoints +- Нет APIRouter — всё на `app.get/post` + +**Рекомендация:** Разделить на `routes/` с `APIRouter`: +``` +apps/api/app/routes/ + ├── activity.py # /api/v1/activity, /spots/* + ├── submissions.py # catch-reports, screenshots + ├── admin.py # /admin/* + ├── community.py # observations, source-status + ├── records.py # /records, /imports + └── catalog.py # fishes, waterbodies, baits +``` + +### 2.2 `activity.py` — ⚠️ Есть риски при scale + +**Плюсы:** +- Детерминированная формула, покрыта тестами +- Cap confidence для 1-2 игроков (A07) +- Exponential decay freshness (18h half-life) + +**Риски:** +- Нет индекса на `(spot_id, fish_id)` — `groups` агрегирует в памяти +- При 10k+ approved reports за 72h — полный load + sort в памяти +- `total = len(rows)` — нет `COUNT` запроса, полная выборка для pagination + +**Рекомендация:** +1. Добавить composite index: `ix_catch_report_spot_fish_approved` +2. Для activity endpoint — materialized view или pre-aggregation table +3. Для total — отдельный `COUNT` query вместо `len(rows)` + +### 2.3 `models.py` — ✅ Хорошо структурирован + +**Плюсы:** +- Чёткие enum для SourceType, ModerationStatus, ImportStatus +- Unique constraints на slug, external_id +- JSON поля для raw_payload, changes, provenance +- Foreign keys с relationships + +**Проблемы:** +- `source_external_id` unique=True — конфликт при повторном импорте разных sources с одинаковым ID +- `SubmissionAttempt` — нет cleanup cron, растёт бесконечно (хотя есть cleanup в `_check_rate_limit`) +- `ImportRecordEvent` — нет индекса на `(catch_report_id, created_at)` для history query + +### 2.4 `importer.py` — ✅ Хорошо, но есть edge cases + +**Плюсы:** +- Advisory lock для cross-process mutex +- ETag/Last-Modified conditional requests +- Idempotent по external_id SHA-256 +- A12: meaningful changes/provenance в ImportRecordEvent + +**Риски:** +- `html: str | None = None` — тестовый параметр в production signature +- `_unique_slug` — N+1 query loop при коллизиях +- Нет retry policy для HTTP 5xx с exponential backoff + +### 2.5 `retention.py` — ✅ Хорошо + +**Плюсы:** +- Dry-run mode по умолчанию +- Configurable policy через RetentionPolicy +- Handles screenshots, payloads, moderation events +- Автоматический rejection pending reports по TTL + +**Проблемы:** +- `delete_object` callback — должен быть injected, не passed per-call +- Нет bulk delete — loop по reports + +### 2.6 `scheduler.py` vs `community_scheduler.py` — ⚠️ Дублирование + +Оба используют pattern: +```python +while True: + try: + run_due_import() + except Exception: + logger.exception(...) + time.sleep(interval) +``` + +**Рекомендация:** Общий base class или use APScheduler/celery-beat + +--- + +## 3. Frontend (Astro) — детальный разбор + +### 3.1 `index.astro` — ⚠️ Сложная логика в template + +**Проблемы:** +- 50+ строк бизнес-логики в `.astro` файле +- Нет error boundary — catch all делает 503 +- `offset + items.length < totalItems` — работает, но fragile при partial last page +- Нет optimistic UI / loading state + +**Рекомендация:** +1. Вынести fetch logic в `src/lib/fetchers.ts` +2. Добавить `isLoading` state и skeleton UI +3. Error boundary per-section (filters, activity, signals) + +### 3.2 `Layout.astro` — ✅ Хорошо + +**Плюсы:** +- A08: errorPage prop для skip structuredData +- Canonical, OG, Twitter Card, JSON-LD +- robots/noindex для admin/error pages +- Skip link для accessibility + +**Минусы:** +- 12 CSS imports — можно aggregate в `global.css` +- `replaceAll("<", "\\u003c")` — hack для JSON escaping + +### 3.3 `global.css` (~1200 строк) — ⚠️ Все стили вместе + +**Проблемы:** +- Все компоненты в одном файле — сложно navigate +- Нет CSS modules или scoped styles + +**Рекомендация:** +- Разделить на `components/`, `pages/`, `utilities/` +- Astro scoped styles для компонентов + +--- + +## 4. Security — ✅ В целом хорошо, есть улучшения + +### ✅ Реализовано +| Мера | Статус | +|------|--------| +| Rate limiting (PostgreSQL advisory lock) | ✅ | +| Idempotency-Key header | ✅ (A05) | +| HMAC client_hash вместо IP | ✅ | +| Screenshot validation (MIME, size, EXIF removal) | ✅ | +| Admin Bearer token + Caddy Basic Auth | ✅ | +| Security headers (HSTS, X-Frame-Deny, etc.) | ✅ | +| Production settings validation | ✅ | +| pip-audit в CI | ✅ | + +### ⚠️ Улучшения +| Проблема | Приоритет | +|----------|----------| +| **CORS origins** — dev defaults `localhost:4321`, production требует HTTPS | Medium | +| **ADMIN_TOKEN** — default `change-me-in-production` в compose.yaml | High (docs) | +| **X-Content-Type-Options** — только на admin/catch-reports, не на всех | Low | +| **Content-Security-Policy** — отсутствует | Medium | +| **Rate limit** — 5 requests per 10 min, нет per-endpoint limits | Low | +| **Screenshot upload** — нет CSRF protection (POST без token) | Medium | + +--- + +## 5. Performance — ⚠️ Есть узкие места + +### 5.1 Database + +**Проблемы:** +- `activity` endpoint: full table scan + in-memory sort +- `spot_detail`: `count_since` — Python loop по reports +- `public_spot_pages`: 3-way JOIN + DISTINCT + offset pagination + +**Рекомендации — SQL индексы:** +```sql +-- Добавить индексы +CREATE INDEX ix_catch_report_spot_fish_approved + ON catch_report(spot_id, fish_id, moderation_status, reported_at); + +CREATE INDEX ix_catch_report_waterbody_approved_reported + ON catch_report(waterbody_id, moderation_status, reported_at); + +CREATE INDEX ix_catch_report_official_records + ON catch_report(source_type, caught_at, weight_g) + WHERE source_type = 'official_record'; +``` + +### 5.2 Caching + +**Текущее:** +- In-memory cache: 20s TTL, 128 keys, process-local +- `public_cache.invalidate()` на moderation/publish + +**Проблемы:** +- Multi-process: каждый API worker имеет свой cache +- Нет cache warming на restart +- Community observations не кэшируются + +**Рекомендация:** Redis или shared memory (SHM) для multi-process + +### 5.3 Frontend + +**Плюсы:** +- Astro SSR — no client JS for initial render +- `fetchpriority="high"` для hero image +- Reduced motion support + +**Улучшения:** +- Нет lazy loading для нижеfold images +- Нет prefetch для `/spots/[id]` +- SignalFeed — нет intersection observer для "load more" + +--- + +## 6. Testing — ✅ 130 passed, 1 skipped + +### ✅ Хорошее покрытие +- API endpoints (test_api.py) +- Import idempotency (test_importer.py) +- Community CLI state/validation (test_community_cli.py) +- Readiness aggregation (test_readiness.py) +- Rate limit with Docker chain (test_rate_limit.py) + +### ⚠️ Пропущенные сценарии +| Тест | Приоритет | +|------|----------| +| **Retention cleanup** — нет интеграционного теста | High | +| **Screenshot validation** — edge cases (PNG vs JPEG magic bytes) | Medium | +| **Community scheduler retry backoff** — 30min → 24h | Medium | +| **Activity calculation** — edge cases (0 players, all same player) | Low | +| **Bootstrap E2E** — test-production-bootstrap.sh не в CI | Medium | +| **Astro accessibility** — audit:axe есть, но нет automated checks | Low | + +--- + +## 7. Deployment & Operations + +### ✅ Хорошее +- Compose production с healthchecks +- Backup/restore scripts +- Monitoring docs (disk, TLS, readiness) +- Data retention policy +- Preflight checks before deploy + +### ⚠️ Улучшения +| Проблема | Решение | +|----------|---------| +| **Нет blue-green/canary** | Добавить rolling update в deploy script | +| **Database migration rollback** | Нет test-rollback в bootstrap | +| **Log rotation** — Docker json-file, 10MB × 5 | OK, но нет centralized logging | +| **No distributed tracing** | Добавить OpenTelemetry для request_id propagation | +| **No metrics export** | Нет Prometheus metrics (request count, latency, error rate) | + +--- + +## 8. Documentation + +### ✅ Хорошее +- README.md — comprehensive (150+ строк) +- docs/ — recovery plans, audit reports, roadmap +- deploy/README.md — production steps +- data-policy.md, retention, monitoring + +### ⚠️ Улучшения +| Проблема | Решение | +|----------|---------| +| **API documentation** — FastAPI auto-docs, но нет OpenAPI spec file | Добавить `openapi.json` в repo | +| **Data model ER diagram** | Добавить визуальную схему | +| **Architecture decision records (ADR)** | Для key decisions (Astro vs Next, in-memory cache, etc.) | +| **Runbook for incidents** | Что делать при Postgres full, MinIO down, etc. | + +--- + +## 9. Код-стиль и maintainability + +### ✅ Хорошее +- Type hints everywhere +- `from __future__ import annotations` +- Pydantic v2 models +- Alembic migrations + +### ⚠️ Улучшения +| Проблема | Пример | +|----------|--------| +| **Long lines** | `create_catch_report` — 70+ char lines | +| **Magic numbers** | `55 * min(1, weighted / 12)` — вынести константы | +| **Inline SQL** | `text("SELECT pg_try_advisory_lock(:key)")` — вынести в constants | +| **Duplicate URL patterns** | `"/api/v1/admin/"` repeated in middleware and endpoints | + +--- + +## 10. Приоритизированный список улучшений + +### 🔴 High Priority (блокирующие/рисковые) + +1. **Разделить main.py на routers** — SRP, testability, reviewability +2. **Добавить database indexes** — `spot_id + fish_id + moderation_status`, `source_type + official_records` +3. **Retention integration test** — verify cleanup works end-to-end +4. **Screenshot CSRF protection** — form submission без CSRF token +5. **Bootstrap E2E в CI** — `test-production-bootstrap.sh` должен run на push + +### 🟡 Medium Priority (улучшения) + +6. **Shared cache (Redis)** — вместо in-memory для multi-process +7. **Content-Security-Policy header** — добавить base-uri, script-src +8. **Error boundaries в Astro** — per-section error handling +9. **Materialized view для activity** — pre-aggregation вместо in-memory sort +10. **OpenAPI spec export** — `openapi.json` в repo + swagger UI +11. **Metrics export (Prometheus)** — request latency, error rate, queue depth +12. **Screenshot validation test** — magic bytes, MIME mismatch + +### 🟢 Low Priority (косметика/технический долг) + +13. **CSS modularization** — split global.css into components +14. **Extract constants** — 55, 25, 20, 12, 6, 3 → named constants +15. **Scheduler base class** — DRY для official + community scheduler +16. **Prefetch links** — `/spots/[id]` prefetch на hover +17. **ADR documentation** — record architecture decisions +18. **Incident runbook** — playbooks для common failures + +--- + +## Итоговая оценка + +| Категория | Оценка | Комментарий | +|-----------|--------|-------------| +| **Архитектура** | 8/10 | Чистое разделение, но main.py — god object | +| **Безопасность** | 7.5/10 | Хорошие основы, нужно CSP + CSRF | +| **Производительность** | 6.5/10 | Indexes + caching critical для scale | +| **Тестирование** | 8/10 | 130 tests, но пропущены retention + bootstrap | +| **Deploy/Ops** | 7.5/10 | Good compose, no metrics/tracing | +| **Документация** | 8.5/10 | Comprehensive README, missing runbook | +| **Code Quality** | 7.5/10 | Type hints, но long files + magic numbers | + +**Общий балл: 7.6/10** — крепкий проект с хорошими основами, требует refactor main.py и database indexes для production scale. + +--- + +## История ревизий + +| Дата | Автор | Изменения | +|------|-------|----------| +| 2026-09-10 | AI | Полный аудит: архитектура, backend, frontend, security, performance, testing, deploy, docs | +| 2026-09-08 | (предыдущий) | Базовый аудит UI/UX, T01/T02 fixes | + +--- + +## Что делать дальше (рекомендации) + +### Немедленно (до продакшена) +1. Разделить main.py на routers (минимум 3 файла) +2. Создать и применить SQL индексы +3. Добавить retention integration test +4. Добавить CSRF token для screenshot upload +5. Добавить `test-production-bootstrap.sh` в CI workflow + +### В течение спринта +6. Внедрить Redis для shared caching +7. Добавить CSP headers +8. Вынести fetch logic из index.astro в fetchers.ts +9. Добавить OpenAPI spec export +10. Написать incident runbook + +### По возможности +11. Materialized view для activity +12. Prometheus metrics +13. CSS modularization +14. ADR documentation +15. Scheduler base class diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 32ea540..5ee3a23 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2,15 +2,15 @@ Этот файл — единственный актуальный список задач. Завершённые аудиты сохранены как история в [PROJECT_AUDIT_2026-09-08.md](PROJECT_AUDIT_2026-09-08.md), [REGRESSION_AUDIT_2026-09-09.md](REGRESSION_AUDIT_2026-09-09.md) и [RECOVERY_PLAN_2026-09-10.md](RECOVERY_PLAN_2026-09-10.md); их старые чекбоксы не являются текущей очередью. -Последняя сверка: **14 сентября 2026**. +Последняя сверка: **15 сентября 2026**. Подтверждено: - [x] пакет восстановления A01–A13 завершён; итог и доказательства собраны в [RECOVERY_FIXES_REPORT.md](RECOVERY_FIXES_REPORT.md); -- [x] полный Python suite: **157 passed, 1 skipped**; skip относится к интеграционной проверке PostgreSQL и покрывается Docker-приёмкой; +- [x] полный Python suite: **178 passed, 1 skipped**; skip относится к интеграционной проверке PostgreSQL и покрывается Docker-приёмкой; - [x] Astro check: 40 файлов, **0 errors / 0 warnings / 0 hints**; production build проходит; - [x] API после миграции healthy; `apps/api/tests/test_api.py`: **20 passed**; -- [x] граф Alembic линеен и имеет единственную голову `0016`; CI применяет её на чистой PostgreSQL, полный production bootstrap запускается отдельным еженедельным drill; +- [x] граф Alembic линеен и имеет единственную голову `0018`; CI применяет её на чистой PostgreSQL, полный production bootstrap запускается отдельным еженедельным drill; - [x] изолированный production bootstrap проходит Caddy adapt, scheduler validation и Playwright-сценарий отправки/модерации без обращения к внешним источникам; - [x] Astro + FastAPI + PostgreSQL остаются целевым стеком; Next.js и Vinext не используются. @@ -38,14 +38,43 @@ - [x] **B16 · Самодостаточное хранение media dataset.** Все управляемые оригиналы из `data/media/files/` версионируются в Git вместе с manifest; локальный клон содержит полный исследовательский набор. Пользовательские скриншоты остаются в MinIO/S3, а наличие файла в Git не означает разрешение на публикацию без статуса `approved`. - [ ] **B17 · Полный каталог рыб RF4DB — финальная загрузка.** Разрешённый RF4DB дал все 252 подписанных кандидата; 227 совпадений с RF4MAP сохранены как provenance-only `duplicate`, 15 уникальных файлов скачаны, а 10 URL (9 уникальных названий) остаются в очереди после корректно остановленного TLS-сбоя. Все поддомены RF4DB объединены одним 30-минутным cooldown. Завершить следующим разрешённым batch-окном и отдельно утвердить новые файлы. - [ ] **B18 · Каталог водоёмов RF4DB.** После cooldown проиндексировать 19 русских карточек водоёмов, проверить полноразмерные карты и поставить только уникальные изображения в media queue. -- [ ] **B19 · Каталог снастей RF4DB.** После водоёмов проиндексировать категории gear, построить crosswalk по типу, бренду, семейству и названию; не считать каталог полным до получения проверяемого общего счётчика. +- [ ] **B19 · Каталог снастей RF4DB.** Подготовительный media-контур; полный справочник, crosswalk, связи с уловами и публичные страницы выполняются по пакету **G01–G09** ниже. Не считать каталог полным до получения проверяемого общего счётчика по категориям. - [x] **B20 · Аудит качества рыбных изображений.** Offline `media_cli --quality-report` проверяет фактические dimensions опубликованных файлов, отдельно считает неизбежный upscale в карточках и известные альтернативы. На 14.09: 227 из 243 опубликованных рыб имеют только 48×48 PNG RF4MAP и растягиваются до 180 px; 16 имеют 1024×1024 WebP. Для всех 227 низких файлов уже известны альтернативные URL RF4DB. Совпадение сущности больше нельзя считать достаточным основанием пропустить потенциально более качественный файл. -- [ ] **B21 · Очередь quality-upgrade.** Не менять текущие approved-файлы до готовности замены. Перевести 227 RF4DB `duplicate` в отдельное состояние `upgrade_queued`, сохранив `duplicate_of`, и загружать партиями до 40 в разрешённые 30-минутные окна. Ошибка останавливает домен по существующим правилам; публикация старой версии при этом не прерывается. -- [ ] **B22 · Сравнение вариантов разных источников.** После загрузки для каждой рыбы сравнить реальные dimensions, размер файла, MIME, прозрачность, aspect ratio и визуальное соответствие подписи. Минимальный технический порог для основной карточки — 256 px по меньшей стороне, предпочтительный — 512 px; маленький файл сохранять только как fallback. Источник RF4DB/RF4MAP/официальный RF4 не получает автоматического приоритета: выбирается лучший прошедший проверку файл. +- [ ] **B21 · Очередь quality-upgrade.** Текущие approved-файлы не меняются до готовности замены. 15.09 отдельная очередь создана для 226 прямых RF4DB-альтернатив с сохранением `duplicate_of`; первая партия из 40 файлов загружена без ошибок в `upgrade_stored`, ещё 186 замен и 10 кандидатов недостающих рыб ожидают следующих разрешённых 30-минутных окон. Один provenance-only `duplicate`, не связанный с низкоразрешённым published-файлом, намеренно не переведён. Ошибка останавливает домен по существующим правилам; публикация старой версии при этом не прерывается. +- [ ] **B22 · Сравнение вариантов разных источников.** Offline-команда `--compare-quality-upgrades` сопоставляет сохранённые кандидаты с опубликованными fallback по `duplicate_of` и проверяет dimensions, размер файла, MIME, прозрачность и aspect ratio. Первая партия: 40/40 кандидатов — прозрачные WebP 1024×1024, все прошли порог 256 px и сохранили пропорции; технических ошибок нет. До переключения остаётся визуально подтвердить соответствие подписи. Маленький файл сохраняется как fallback; источник RF4DB/RF4MAP/официальный RF4 не получает автоматического приоритета. - [ ] **B23 · Безопасное продвижение и provenance.** Добавить связь `supersedes`/`replaced_by`, отдельное решение review и атомарное переключение публичного `entity_key`; сохранить обе исходные ссылки и возможность отката. На сайте показывать источник именно выбранного изображения, а в раскрываемом provenance — все проверенные варианты. Не удалять старый Git-файл в том же коммите, где включается новый. - [ ] **B24 · Производные размеры.** После выбора оригиналов генерировать детерминированные WebP/AVIF thumbnails для каталога и отдельный крупный вариант для detail, фиксировать хэши производных в manifest и отдавать `srcset`. Это уберёт загрузку 1024×1024 на каждой маленькой карточке и исключит browser-upscale 48×48. - [ ] **B25 · Визуальная приёмка media.** Собирать контактный лист «старое / кандидат / выбранное» с названием и источником; вручную проверить минимум все замены и репрезентативные desktop/mobile страницы в light/dark. Gate: нет битых файлов, искажённых пропорций, ложных соответствий, обрезанного объекта и изображений ниже 256 px без явной пометки «низкое разрешение». +### Каталог водоёмов, карты и координаты + +- [x] **W01 · Canonical-каталог RF4DB.** 16.09 через браузерный контекст получен и проверен индекс `/ru/maps`: 19/19 карточек, source slug/ID, русское название, уровень открытия, число видов рыб и URL изображения сохранены в [`data/waterbodies/rf4db-catalog-2026-09-16.json`](../data/waterbodies/rf4db-catalog-2026-09-16.json). Добавлена команда `python -m app.cli import-waterbody-catalog --input ...` для применения снимка в БД. Изображения остаются кандидатами без автоматической роли `map` или `cover`; detail-данные выполняются отдельно по W02. +- [ ] **W02 · Карточки водоёмов RF4DB.** Строгий fixture-based parser `rf4db-waterbody` расширен под реальный DOM `/fishes/` и `/positions/`, regression-тесты, безопасный `update_waterbody_detail` и атомарный batch `update_waterbody_details`/`import-waterbody-details` готовы; первая detail-карточка (`level_000_home`) сохранена браузером как snapshot. Осталось последовательно разобрать 18 detail-страниц с общим 30-минутным cooldown домена. +- [ ] **W03 · Модель и provenance.** В модель `Waterbody`, API-каталог и миграции `0017`/`0019`/`0020` добавлены nullable-поля provenance, счётчик видов и detail-факты; идемпотентные upsert-функции обновляют только подтверждённые source identity и не удаляют исчезнувшие строки. Осталось применить их к проверенному canonical-каталогу и отдельно разделить игровой и редакционный тексты при подключении detail-данных. +- [ ] **W04 · Классификация изображений.** Добавлены допустимые роли `waterbody_cover`, `waterbody_map`, `waterbody_depth_map`, `waterbody_screenshot` и проверка их назначения только через review для canonical waterbody. Кандидаты по-прежнему не получают роль автоматически. Осталось наполнить очередь detail-изображениями и провести contact-sheet review с проверкой dimensions, MIME, SHA-256, соответствия названию и источника. +- [ ] **W05 · Crosswalk источников.** Добавлен offline-конструктор консервативных предложений: нормализуются только точные имена/алиасы, неоднозначные и unmatched строки не получают canonical key; отсутствие ID выдаётся лишь диагностикой и не считается удалением. Осталось подать реальные RF4DB/RF4MAP/RF4 Posts identities и вручную подтвердить результаты, включая три ранее отмеченных отсутствующих RF4MAP объекта. +- [ ] **W06 · Координаты и точность.** В `ExternalObservation`, staging, provenance опубликованного улова и публичных activity/spot-ответах добавлены `coordinate_raw`, `coordinate_precision = exact | approximate | area | missing` и список источников; RF4DB/RF4-STAT/RF4MAP/RF4 Posts parsers теперь протягивают исходную строку, включая строки без доступных числовых координат. Карточка точки показывает точность рядом с координатами. Осталось провести browser QA. +- [ ] **W07 · Публичный API и страницы.** API и detail-страница теперь выводят подтверждённые detail-факты водоёма: описание, уровень, количество видов, алиасы, число ссылок на точки и отдельный счётчик изображений-кандидатов; источники и непроверенные media не смешиваются. Осталось подключить только проверенные waterbody media roles и завершить browser QA, включая различение карты, заставки и абстрактного отпечатка. +- [ ] **W08 · Приёмка и эксплуатация.** Добавить fixture-based parser tests, offline catalog/media audit, проверку 19 canonical entities, отсутствие битых файлов и browser QA desktop/mobile. Сетевые тесты не выполнять; регулярный импорт оставить opt-in и под общим cooldown/backoff. + +### Каталог снастей, наживок и прочей оснастки + +Этот пакет повторяет жизненный цикл W01–W08, но не смешивает разные уровни +описания. Наживка/приманка — предмет, который указан в улове; снасть — +компонент комплекта (удилище, катушка, леска, крючок и т. п.); оснастка — +собранная схема или монтаж (например, донная, поплавочная, method). Число +найденных изображений приманок не считается размером полного каталога. + +- [ ] **G01 · Canonical-каталог RF4DB.** Получить разрешённый индекс категорий gear и проверить полный набор доступных страниц по типам: `bait`, `lure`, `rod`, `reel`, `line`, `hook`, `rig`, `float`, `sinker`, `other`. Сохранить исходный slug/ID, русское название, категорию, подкатегорию, бренд, семейство, игровые ограничения/уровень и source URL. Отсутствие общего счётчика или закрытая категория должны оставаться явно `unknown`, а не превращаться в оценку полноты. +- [ ] **G02 · Карточки предметов и оснасток.** Подготовить строгие fixture-based parsers для списка и detail-страницы: характеристики, варианты, совместимость, изображения, связанные типы монтажа и исходные значения. Парсер должен различать отсутствующее поле, «не применимо» и фактическое нулевое значение; при неполном или изменившемся ответе сохранять предыдущие подтверждённые данные. Detail-запросы выполнять только для выбранных карточек и с общим 30-минутным cooldown домена. +- [ ] **G03 · Модель и provenance.** Расширить `bait` до канонического `tackle_item` либо выполнить безопасную миграцию с обратной совместимостью API: `kind`, `category`, `subcategory`, `brand`, `family`, `source_system`, `source_external_id`, `source_url`, `source_checked_at`, `raw_payload`. Отдельно моделировать `rig`/монтаж и его компоненты; не хранить удилище, катушку и монтаж в одном свободном `rig_type`. Для каждой характеристики сохранить источник и статус проверки. +- [ ] **G04 · Crosswalk и нормализация.** Построить offline-crosswalk между RF4DB, официальными рекордами, RF4MAP, RF4 Posts и локальным справочником. Нормализовать регистр, пробелы, дефисы, единицы и локализацию; предлагать совпадение только при точном имени/алиасе плюс совместимой категории. Неоднозначные, брендовые варианты и unmatched-строки отправлять на review без автоматического canonical key; исходное значение всегда сохранять. +- [ ] **G05 · Связи с уловами и источниками.** Протянуть канонические предметы и монтажи через community import, официальные записи и форму улова, сохранив `raw_payload` и список missing fields. Поддержать несколько предметов в одном комплекте, порядок/роль компонента и источник каждой связи; старый `bait_id` и текстовые значения не терять при миграции. Публикация полного наблюдения по-прежнему требует подтверждённых соответствий, а не простого совпадения строки. +- [ ] **G06 · API и публичный каталог.** Добавить пагинированные каталоги и detail endpoints с фильтрами по категории, бренду, семейству и уровню, а также безопасные ссылки из улова/точки на использованную приманку, снасть и монтаж. Показывать только подтверждённые характеристики, источник, свежесть и неполноту; не выдавать рейтинг эффективности, если его нельзя объяснить числом наблюдений, игроками, периодом и качеством источников. +- [ ] **G07 · Аналитика сочетаний и рекомендации.** После появления достаточных данных считать отдельно «водоём + рыба + предмет», «точка + рыба + предмет» и «способ ловли + монтаж». Зафиксировать минимальный объём выборки, защиту от одного игрока/дубликатов и decay по свежести; разделить факт использования, частоту и рекомендацию. Пустая или малая выборка должна показывать «данных мало», а не советовать конкретную снасть. +- [ ] **G08 · Медиа и качество.** Разнести media roles для `tackle_item`, `bait`, `rig` и общего reference; связать варианты через `entity_key`, `duplicate_of`, `supersedes`/`replaced_by`. Проверять dimensions, MIME, SHA-256, прозрачность, aspect ratio, подпись и категорию; не переключать approved-файл автоматически, не считать userguide-скриншот карточкой предмета и не публиковать media-кандидатов без review и разрешённого provenance. +- [ ] **G09 · Приёмка и эксплуатация.** Добавить fixture/regression tests, offline catalog/crosswalk/media audits, проверку идемпотентности и сохранения старых данных при сбое, API/UI acceptance для пустых, неоднозначных и многокомпонентных комплектов, browser QA desktop/mobile и query-plan gate для фильтров/сочетаний. Сетевые тесты не выполнять; импорт оставить opt-in, последовательным и под общим cooldown/backoff. Закрывать пакет только после проверяемого счётчика по каждой категории либо явной фиксации `unknown`. + ### Тёмная тема Реализовывать последовательно: сначала семантическая палитра и системный режим, затем ручное управление и полировка компонентов. Тёмная тема должна сохранять полевую эстетику RF4 Spotter, а не быть механической инверсией светлой. @@ -91,6 +120,18 @@ - [x] **M05 · Защита от параллельных решений.** Обе очереди отдают `moderation_version`; mapping/publish/reject/approve/delete требуют увиденную версию и повторно сверяют её под row lock. Успешное решение атомарно увеличивает version, а устаревшая вкладка получает понятный `409` и автоматически перезагружает очередь. Схема обновляется линейной миграцией `0015`. - [ ] **M06 · Персональные роли — после пилота.** Если модераторов станет больше одного, заменить общий токен индивидуальными аккаунтами, короткими сессиями, отзывом доступа и ролями; писать идентификатор оператора в аудит. Для одного владельца альфы не добавлять отдельный auth-сервис заранее. +### План доведения административной панели + +Этот план фиксирует следующий рабочий контур поверх уже закрытых M01–M05. Текущий MVP функционален для одного владельца альфы, но ниже перечислены эксплуатационные пробелы, найденные аудитом, и критерии их закрытия. + +- [ ] **A01 · Защита маршрутов и границы сессии.** Покрыть точный `/admin` и `/admin/*` единым Caddy Basic Auth, выставлять `noindex` и `no-store` для всех административных ответов, проверить отсутствие обхода через API и корректные `401/429`. Критерий: автоматический proxy-smoke для `/admin`, страниц и `/api/v1/admin/*` с отсутствующим, неверным и валидным доступом. +- [ ] **A02 · Единая auth/error UX.** Привести dashboard, moderation и external sources к одинаковому поведению при `401`, `409`, `429`, `5xx`, loading/empty-состояниях: понятное сообщение, блокировка повторной отправки, возврат к входу только при истёкшей авторизации. Критерий: regression-тесты на каждый ответ и сохранение введённой причины. +- [ ] **A03 · Полный single-owner workflow.** Добавить пагинацию очереди уловов, кнопку запуска официального импорта и отображение результата/истории, безопасные статусы источников с возрастом данных, cooldown/backoff и ручное обновление очередей. Критерий: владелец может пройти путь «импорт → проверка → решение → история» без API/CLI; старые данные сохраняются при сбое импорта. +- [ ] **A04 · Контур медиа-проверки.** Сделать admin-экран для просмотра approved/upgrade_queued медиа, исходника, размеров, производных и provenance; добавить approve/rollback только через существующие безопасные состояния. Критерий: ни одна публичная замена не происходит без явного решения и проверяемого manifest-а. +- [ ] **A05 · Многопользовательский доступ.** После пилота заменить общий Bearer-токен персональными аккаунтами и короткими серверными сессиями с отзывом, ролями read-only/moderator/importer/owner, operator ID в аудите и журналом входов. Критерий: минимальные права реально ограничивают действия, logout/revoke инвалидируют сессию на сервере. +- [ ] **A06 · Browser/accessibility acceptance.** Проверить `/admin`, moderation и external sources в 320/390/768/1280 px: клавиатура, focus order, screen reader labels, reduced motion, forced colors, темы, ошибки/пустые очереди и конфликт `409`. Критерий: Playwright + axe без блокирующих дефектов и ручная визуальная проверка. +- [ ] **A07 · Production gate.** Выполнить preflight с реальными секретами, проверить Caddy Basic + API Bearer, закрытые внутренние порты, backup/restore PostgreSQL и MinIO, readiness, no-store и внешний smoke после деплоя. Критерий: acceptance-runbook пройден, rollback и процедура отзыва доступа документированы. + ## Готовность открытой альфы — требуется сервер или внешний сервис - [ ] Купить/подготовить Linux-сервер и подтвердить его публичный IPv4/IPv6.