feat: extend admin operations and media review

This commit is contained in:
ik
2026-09-16 19:50:01 +07:00
parent 4c75db1f74
commit 9bcb019d3a
13 changed files with 1063 additions and 28 deletions
+59
View File
@@ -29,6 +29,17 @@ def published_assets(entity_type: str | None = None) -> list[dict]:
"image_url": f"/api/v1/media/assets/{item['sha256']}", "image_url": f"/api/v1/media/assets/{item['sha256']}",
"source_system": source, "source_system": source,
"source_url": source_page, "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"])) 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(): if not target.is_relative_to(MEDIA_ROOT.resolve()) or not target.is_file():
return None return None
return target, str(item["content_type"]) 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")
+69 -2
View File
@@ -1,12 +1,12 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from typing import Annotated, Literal from typing import Annotated, Literal
from uuid import UUID from uuid import UUID
import httpx import httpx
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response 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 import case, func, or_, select
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
@@ -15,6 +15,7 @@ from ..community_review import ExternalReviewError, map_observation, publish_obs
from ..config import settings from ..config import settings
from ..dependencies import Db from ..dependencies import Db
from ..importer import ImportAlreadyRunning, ImportSourceError, import_records 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 ..models import CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Waterbody
from ..public_cache import public_cache from ..public_cache import public_cache
from ..schemas import AdminCatchReportOut, AdminModerationHistoryOut, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationAction, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationUpdate 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) 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") @router.get("/api/v1/admin/diagnostics")
def admin_diagnostics(db: Db, _: Annotated[str, Depends(_admin)]) -> JSONResponse: def admin_diagnostics(db: Db, _: Annotated[str, Depends(_admin)]) -> JSONResponse:
report_counts = {status.value: count for status, count in db.execute( 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 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: def _external_out(item: ExternalObservation) -> ExternalObservationOut:
allowed_payload = { allowed_payload = {
key: value for key, value in (item.payload or {}).items() key: value for key, value in (item.payload or {}).items()
+370 -3
View File
@@ -30,6 +30,17 @@
"title": "Confidence Score", "title": "Confidence Score",
"type": "integer" "type": "integer"
}, },
"coordinate_precision": {
"title": "Coordinate Precision",
"type": "string"
},
"coordinate_sources": {
"items": {
"type": "string"
},
"title": "Coordinate Sources",
"type": "array"
},
"explanation": { "explanation": {
"title": "Explanation", "title": "Explanation",
"type": "string" "type": "string"
@@ -101,7 +112,9 @@
"activity_score", "activity_score",
"confidence_score", "confidence_score",
"explanation", "explanation",
"sources" "sources",
"coordinate_precision",
"coordinate_sources"
], ],
"title": "ActivityOut", "title": "ActivityOut",
"type": "object" "type": "object"
@@ -1610,6 +1623,17 @@
"title": "Catches 7D", "title": "Catches 7D",
"type": "integer" "type": "integer"
}, },
"coordinate_precision": {
"title": "Coordinate Precision",
"type": "string"
},
"coordinate_sources": {
"items": {
"type": "string"
},
"title": "Coordinate Sources",
"type": "array"
},
"description": { "description": {
"anyOf": [ "anyOf": [
{ {
@@ -1660,7 +1684,9 @@
"catches_24h", "catches_24h",
"catches_3d", "catches_3d",
"catches_7d", "catches_7d",
"top_baits" "top_baits",
"coordinate_precision",
"coordinate_sources"
], ],
"title": "SpotOut", "title": "SpotOut",
"type": "object" "type": "object"
@@ -1700,6 +1726,28 @@
}, },
"WaterbodyOut": { "WaterbodyOut": {
"properties": { "properties": {
"description": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Description"
},
"fish_species_count": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Fish Species Count"
},
"id": { "id": {
"format": "uuid", "format": "uuid",
"title": "Id", "title": "Id",
@@ -1713,6 +1761,107 @@
"title": "Slug", "title": "Slug",
"type": "string" "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": { "unlock_level": {
"anyOf": [ "anyOf": [
{ {
@@ -1729,7 +1878,17 @@
"id", "id",
"slug", "slug",
"name_ru", "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", "title": "WaterbodyOut",
"type": "object" "type": "object"
@@ -2651,6 +2810,162 @@
"summary": "Admin Start Official Import" "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": { "/api/v1/admin/moderation-history": {
"get": { "get": {
"operationId": "admin_moderation_history_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" "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": { "/api/v1/baits": {
"get": { "get": {
"operationId": "baits_api_v1_baits_get", "operationId": "baits_api_v1_baits_get",
+29
View File
@@ -56,6 +56,17 @@ def test_activity_filters_and_explains_score() -> None:
assert payload["items"][0]["sources"] == ["manual-import"] 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: def test_invalid_period_is_rejected() -> None:
assert client.get("/api/v1/activity?hours=13").status_code == 422 assert client.get("/api/v1/activity?hours=13").status_code == 422
assert client.get("/api/v1/activity?sort=unknown").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()) 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: def test_liveness_does_not_probe_dependencies() -> None:
response = client.get("/health?token=must-not-be-logged") response = client.get("/health?token=must-not-be-logged")
assert response.json() == {"status": "ok"} assert response.json() == {"status": "ok"}
+2 -1
View File
@@ -11,6 +11,7 @@ import "../styles/tackle-glyph.css";
import "../styles/empty-states.css"; import "../styles/empty-states.css";
import "../styles/alpha-banner.css"; import "../styles/alpha-banner.css";
import "../styles/signal-pagination.css"; import "../styles/signal-pagination.css";
import "../styles/pagination.css";
import "../styles/dashboard-polish.css"; import "../styles/dashboard-polish.css";
import "../styles/loading-states.css"; import "../styles/loading-states.css";
import "../styles/theme.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 siteUrl = import.meta.env.PUBLIC_SITE_URL || "https://rf4spotter.ru";
const canonical = new URL(path, siteUrl).toString(); const canonical = new URL(path, siteUrl).toString();
const socialImage = new URL(image, 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" }; const websiteJsonLd = { "@type": "WebSite", name: "RF4 Spotter", url: siteUrl, inLanguage: "ru" };
// A08: Skip structuredData on error pages (explicit errorPage prop) // A08: Skip structuredData on error pages (explicit errorPage prop)
// Don't infer error from noindex alone — main page can have noindex on 422 // Don't infer error from noindex alone — main page can have noindex on 422
@@ -19,7 +19,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
<nav data-pages aria-label="Страницы очереди" hidden> <nav data-pages aria-label="Страницы очереди" hidden>
<button data-action="secondary" type="button" data-previous>Предыдущая</button> <button data-action="secondary" type="button" data-previous>Предыдущая</button>
<span data-page-number aria-live="polite"></span> <span data-page-number aria-live="polite"></span>
<button data-action="secondary" type="button" data-next>Следующая</button> <button data-action="secondary" type="button" data-next>Следующая</button><button data-action="secondary" type="button" data-refresh>Обновить</button>
</nav> </nav>
<p class="admin-shortcuts"><kbd>S</kbd> подсказать · <kbd>M</kbd> сопоставить · <kbd>P</kbd> опубликовать карточку с фокусом</p> <p class="admin-shortcuts"><kbd>S</kbd> подсказать · <kbd>M</kbd> сопоставить · <kbd>P</kbd> опубликовать карточку с фокусом</p>
</section> </section>
@@ -32,6 +32,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
const pages = document.querySelector<HTMLElement>("[data-pages]"); const pages = document.querySelector<HTMLElement>("[data-pages]");
const previous = document.querySelector<HTMLButtonElement>("[data-previous]"); const previous = document.querySelector<HTMLButtonElement>("[data-previous]");
const next = document.querySelector<HTMLButtonElement>("[data-next]"); const next = document.querySelector<HTMLButtonElement>("[data-next]");
const refresh = document.querySelector<HTMLButtonElement>("[data-refresh]");
const pageNumber = document.querySelector<HTMLElement>("[data-page-number]"); const pageNumber = document.querySelector<HTMLElement>("[data-page-number]");
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar"); const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]"); const logout = document.querySelector<HTMLButtonElement>("[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 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 keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
const options = (items: Record<string, string>[], selected?: unknown) => items.map(item => `<option value="${esc(item.slug)}" ${item.slug === selected ? "selected" : ""}>${esc(item.name_ru)}</option>`).join(""); const options = (items: Record<string, string>[], selected?: unknown) => items.map(item => `<option value="${esc(item.slug)}" ${item.slug === selected ? "selected" : ""}>${esc(item.name_ru)}</option>`).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<string, string> | 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<string, string> | undefined)?.Authorization) keepSession(); return response.json(); }
async function loadQueue() { async function loadQueue() {
if (!root || !list) return; error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards(); 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<HTMLSelectElement>('[name="source"]'); if (sourceSelect) sourceSelect.innerHTML = '<option value="">Все источники</option>' + (sources as Record<string, unknown>[]).map(source => `<option value="${esc(source.source_system)}">${esc(source.name)}</option>`).join(""); } 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<HTMLSelectElement>('[name="source"]'); if (sourceSelect) sourceSelect.innerHTML = '<option value="">Все источники</option>' + (sources as Record<string, unknown>[]).map(source => `<option value="${esc(source.source_system)}">${esc(source.name)}</option>`).join(""); }
@@ -89,6 +90,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
} }
previous?.addEventListener("click", () => changePage(-50)); previous?.addEventListener("click", () => changePage(-50));
next?.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 : "Ошибка загрузки."); } }); 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 : "Ошибка фильтрации."); } }); 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("Вы вышли из административной панели.")); logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
+12 -9
View File
@@ -9,6 +9,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
<p class="privacy">Токен существует только в памяти вкладки. Сессия завершится после 15 минут бездействия.</p> <p class="privacy">Токен существует только в памяти вкладки. Сессия завершится после 15 минут бездействия.</p>
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div> <div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div>
<div class="notice error" data-admin-error role="alert" hidden></div> <div class="notice error" data-admin-error role="alert" hidden></div>
<div class="notice success" data-admin-status role="status" hidden></div>
<section class="admin-dashboard-content" aria-live="polite"></section> <section class="admin-dashboard-content" aria-live="polite"></section>
</main> </main>
<script> <script>
@@ -16,37 +17,39 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
const login = document.querySelector<HTMLFormElement>(".admin-login"); const login = document.querySelector<HTMLFormElement>(".admin-login");
const content = document.querySelector<HTMLElement>(".admin-dashboard-content"); const content = document.querySelector<HTMLElement>(".admin-dashboard-content");
const error = document.querySelector<HTMLElement>("[data-admin-error]"); const error = document.querySelector<HTMLElement>("[data-admin-error]");
const status = document.querySelector<HTMLElement>("[data-admin-status]");
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar"); const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]"); const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
let token = ""; let token = "";
let sessionTimer: ReturnType<typeof setTimeout> | undefined; let sessionTimer: ReturnType<typeof setTimeout> | undefined;
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[char] ?? char)); const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[char] ?? char));
const fail = (message: string) => { if (error) { error.textContent = message; error.hidden = false; } }; const fail = (message: string) => { if (error) { error.textContent = message; error.hidden = false; } };
const succeed = (message: string) => { if (error) error.hidden = true; if (status) { status.textContent = message; status.hidden = false; } };
const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (content) content.innerHTML = ""; if (message) fail(message); }; const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (content) content.innerHTML = ""; if (message) fail(message); };
const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); }; const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
async function authorizedJson(path: string) { const response = await fetch(`${root?.dataset.apiUrl}${path}`, {headers:{Authorization:`Bearer ${token}`}}); if (response.status === 401 || response.status === 429) { endSession(); throw new Error(response.status === 429 ? "Слишком много попыток входа. Повторите позже." : "Неверный или истёкший административный токен."); } if (!response.ok) throw new Error("Не удалось загрузить административные данные."); keepSession(); return response.json(); } async function authorizedJson(path: string) { const response = await fetch(`${root?.dataset.apiUrl}${path}`, {headers:{Authorization:`Bearer ${token}`}}); if (response.status === 401 || response.status === 429) { endSession(); throw new Error(response.status === 429 ? "Слишком много попыток входа. Повторите позже." : "Неверный или истёкший административный токен."); } if (!response.ok) throw new Error("Не удалось загрузить административные данные."); keepSession(); return response.json(); }
async function publicJson(path: string) { const response = await fetch(`${root?.dataset.apiUrl}${path}`); return response.ok ? response.json() : []; }
async function loadDashboard() { async function loadDashboard() {
if (!content) return; if (!content) return;
error?.setAttribute("hidden", ""); content.setAttribute("aria-busy", "true"); content.innerHTML = '<div class="loading-grid" aria-hidden="true"><div class="loading-card"></div><div class="loading-card"></div></div>'; error?.setAttribute("hidden", ""); status?.setAttribute("hidden", ""); content.setAttribute("aria-busy", "true"); content.innerHTML = '<div class="loading-grid" aria-hidden="true"><div class="loading-card"></div><div class="loading-card"></div></div>';
const diagnostics = await authorizedJson("/api/v1/admin/diagnostics"); const diagnostics = await authorizedJson("/api/v1/admin/diagnostics");
const [imports, sources, history] = await Promise.all([authorizedJson("/api/v1/admin/imports?limit=5"), publicJson("/api/v1/source-status"), authorizedJson("/api/v1/admin/moderation-history?limit=8")]); const [imports, sources, history] = await Promise.all([authorizedJson("/api/v1/admin/imports?limit=5"), authorizedJson("/api/v1/admin/source-status"), authorizedJson("/api/v1/admin/moderation-history?limit=8")]);
const reports = diagnostics.counts?.catch_reports ?? {}; const observations = diagnostics.counts?.external_observations ?? {}; const reports = diagnostics.counts?.catch_reports ?? {}; const observations = diagnostics.counts?.external_observations ?? {};
const sourceLabels: Record<string, string> = {healthy:"Работает",waiting:"Ожидает",stale:"Устарел",disabled:"Отключён",source_changed:"Изменился",temporarily_limited:"Временно недоступен"}; const sourceLabels: Record<string, string> = {healthy:"Работает",waiting:"Ожидает",stale:"Устарел",disabled:"Отключён",source_changed:"Изменился",temporarily_limited:"Временно недоступен"};
const sourceRows = (sources as Record<string, unknown>[]).map(source => { const state = String(source.status); const safeState = Object.hasOwn(sourceLabels, state) ? state : "waiting"; return `<li><span><i class="status-dot ${safeState}"></i>${esc(source.name)}</span><strong>${sourceLabels[safeState]}</strong></li>`; }).join(""); const sourceRows = (sources as Record<string, unknown>[]).map(source => { const state = String(source.status); const safeState = Object.hasOwn(sourceLabels, state) ? state : "waiting"; const cooldown = Number(source.cooldown_seconds ?? 0); const detail = cooldown > 0 ? ` · cooldown ${Math.ceil(cooldown / 60)} мин` : source.backoff_recommended ? " · backoff рекомендован" : ""; return `<li><span><i class="status-dot ${safeState}"></i>${esc(source.name)}<small>${esc(detail)}</small></span><strong>${sourceLabels[safeState]}</strong></li>`; }).join("");
const importRows = (imports as Record<string, unknown>[]).map(run => `<li><span>${esc(run.status)}</span><time>${esc(new Date(String(run.started_at)).toLocaleString("ru-RU"))}</time></li>`).join(""); const importLabels: Record<string, string> = {running:"Выполняется",success:"Успешно",partial:"Частично",failed:"Ошибка"};
const importRows = (imports as Record<string, unknown>[]).map(run => { const status = String(run.status); const rows = Number(run.rows_seen ?? 0); const result = rows ? ` · ${rows} строк` : ""; return `<li><span>${esc(importLabels[status] ?? status)}<small>${esc(result)}</small></span><time>${esc(new Date(String(run.started_at)).toLocaleString("ru-RU"))}</time></li>`; }).join("");
const actionLabels: Record<string, string> = {approved:"Одобрено",rejected:"Отклонено",pending:"Возвращено на проверку",published:"Опубликовано",mapped:"Сопоставлено",ready:"Готово"}; const actionLabels: Record<string, string> = {approved:"Одобрено",rejected:"Отклонено",pending:"Возвращено на проверку",published:"Опубликовано",mapped:"Сопоставлено",ready:"Готово"};
const typeLabels: Record<string, string> = {catch_report:"Улов",external_observation:"Внешнее наблюдение"}; const typeLabels: Record<string, string> = {catch_report:"Улов",external_observation:"Внешнее наблюдение"};
const historyRows = (history as Record<string, unknown>[]).map(event => { const action = String(event.action); const type = String(event.entity_type); return `<li><span><b>${esc(typeLabels[type] ?? "Запись")}</b> · ${esc(actionLabels[action] ?? action)}${event.reason ? `<small>${esc(event.reason)}</small>` : ""}</span><time>${esc(new Date(String(event.decided_at)).toLocaleString("ru-RU"))}</time></li>`; }).join(""); const historyRows = (history as Record<string, unknown>[]).map(event => { const action = String(event.action); const type = String(event.entity_type); return `<li><span><b>${esc(typeLabels[type] ?? "Запись")}</b> · ${esc(actionLabels[action] ?? action)}${event.reason ? `<small>${esc(event.reason)}</small>` : ""}</span><time>${esc(new Date(String(event.decided_at)).toLocaleString("ru-RU"))}</time></li>`; }).join("");
content.removeAttribute("aria-busy"); content.innerHTML = `<div class="admin-kpis"><a href="/admin/moderation"><span>Уловы на проверке</span><strong>${esc(reports.pending ?? 0)}</strong><small>Открыть очередь →</small></a><a href="/admin/external-sources"><span>Наблюдения в staging</span><strong>${esc((observations.staged ?? 0) + (observations.mapped ?? 0) + (observations.ready ?? 0))}</strong><small>Проверить источники →</small></a><article><span>Одобрено уловов</span><strong>${esc(reports.approved ?? 0)}</strong><small>Участвуют в статистике</small></article><article><span>Источников включено</span><strong>${esc(diagnostics.counts?.enabled_data_sources ?? 0)}</strong><small>из ${esc(diagnostics.counts?.data_sources ?? 0)}</small></article></div><div class="admin-dashboard-grid"><section><h2>Состояние источников</h2><ul>${sourceRows || "<li>Нет данных</li>"}</ul><a href="/status">Публичная страница состояния →</a></section><section><h2>Последние импорты</h2><ul>${importRows || "<li>Запусков пока нет</li>"}</ul></section><section class="admin-history"><div class="admin-section-head"><h2>Последние решения</h2><button type="button" data-action="secondary" data-history-export>Экспорт JSON</button></div><ul>${historyRows || "<li>Решений пока нет</li>"}</ul><p class="privacy">Экспорт обезличен: без UUID, модератора, причин и исходных данных.</p></section></div>`; content.removeAttribute("aria-busy"); content.innerHTML = `<div class="admin-kpis"><a href="/admin/moderation"><span>Уловы на проверке</span><strong>${esc(reports.pending ?? 0)}</strong><small>Открыть очередь →</small></a><a href="/admin/external-sources"><span>Наблюдения в staging</span><strong>${esc((observations.staged ?? 0) + (observations.mapped ?? 0) + (observations.ready ?? 0))}</strong><small>Проверить источники →</small></a><article><span>Одобрено уловов</span><strong>${esc(reports.approved ?? 0)}</strong><small>Участвуют в статистике</small></article><article><span>Источников включено</span><strong>${esc(diagnostics.counts?.enabled_data_sources ?? 0)}</strong><small>из ${esc(diagnostics.counts?.data_sources ?? 0)}</small></article></div><div class="admin-dashboard-grid"><section><div class="admin-section-head"><h2>Состояние источников</h2><button type="button" data-action="secondary" data-refresh>Обновить</button></div><ul>${sourceRows || "<li>Нет данных</li>"}</ul><a href="/status">Публичная страница состояния →</a><br /><a href="/admin/media">Проверить медиа →</a></section><section><div class="admin-section-head"><h2>Последние импорты</h2><button type="button" data-action="secondary" data-official-import>Запустить импорт</button></div><ul>${importRows || "<li>Запусков пока нет</li>"}</ul><p class="privacy">Импорт обращается к официальному источнику и соблюдает cooldown.</p></section><section class="admin-history"><div class="admin-section-head"><h2>Последние решения</h2><button type="button" data-action="secondary" data-history-export>Экспорт JSON</button></div><ul>${historyRows || "<li>Решений пока нет</li>"}</ul><p class="privacy">Экспорт обезличен: без UUID, модератора, причин и исходных данных.</p></section></div>`;
} }
login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); try { await loadDashboard(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; } catch (cause) { if (content) { content.removeAttribute("aria-busy"); content.innerHTML = ""; } fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } }); login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); try { await loadDashboard(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; } catch (cause) { if (content) { content.removeAttribute("aria-busy"); content.innerHTML = ""; } fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели.")); logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
content?.addEventListener("click", async event => { content?.addEventListener("click", async event => {
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("[data-history-export]"); if (!button) return; const button = (event.target as HTMLElement).closest<HTMLButtonElement>("[data-history-export],[data-official-import],[data-refresh]"); if (!button) return;
button.disabled = true; button.disabled = true;
try { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/moderation-history-export`, {headers:{Authorization:`Bearer ${token}`}}); if (!response.ok) throw new Error(); const blob = await response.blob(); const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.download = "rf4spotter-moderation-history.json"; link.click(); URL.revokeObjectURL(link.href); keepSession(); } try { if (button.hasAttribute("data-refresh")) { await loadDashboard(); succeed("Данные обновлены."); } else if (button.hasAttribute("data-official-import")) { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/imports/official-records`, {method:"POST",headers:{Authorization:`Bearer ${token}`}}); if (response.status === 401) { endSession(); throw new Error("Сессия истекла. Введите токен снова."); } if (response.status === 409) throw new Error("Импорт уже выполняется."); if (response.status === 429) { endSession(); throw new Error("Слишком много попыток. Повторите позже."); } if (response.status === 502) throw new Error("Официальный источник временно недоступен. Старые данные сохранены."); if (!response.ok) throw new Error("Не удалось запустить импорт."); keepSession(); await loadDashboard(); succeed("Импорт запущен. Список запусков обновлён."); } else { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/moderation-history-export`, {headers:{Authorization:`Bearer ${token}`}}); if (!response.ok) throw new Error(); const blob = await response.blob(); const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.download = "rf4spotter-moderation-history.json"; link.click(); URL.revokeObjectURL(link.href); keepSession(); } }
catch { fail("Не удалось выгрузить журнал решений."); } catch (cause) { fail(cause instanceof Error ? cause.message : "Операция не выполнена."); }
finally { button.disabled = false; } finally { button.disabled = false; }
}); });
</script> </script>
+57
View File
@@ -0,0 +1,57 @@
---
import Layout from "../../layouts/Layout.astro";
const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
---
<Layout title="Проверка медиа — RF4 Spotter" noindex>
<section class="form-hero"><div><span class="eyebrow"><b>ADMIN</b> Media review</span><h1>Проверка<br/><em>медиа</em></h1></div><p>Сравнение approved и upgrade_queued файлов перед отдельным редакционным решением.</p></section>
<main class="moderation-app" data-api-url={apiUrl}>
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть медиатеку</button></form>
<p class="privacy">Публичные файлы не переключаются из этого экрана. Токен хранится только в памяти страницы.</p>
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div>
<form class="admin-queue-filters" hidden><label>Тип<select name="entity_type"><option value="">Все типы</option><option value="fish">Рыбы</option><option value="waterbody">Водоёмы</option><option value="tackle">Снасти</option><option value="reference">Справка</option></select></label><label>Состояние<select name="status"><option value="">Все состояния</option><option value="approved">Approved</option><option value="upgrade_queued">Upgrade queued</option><option value="upgrade_stored">Upgrade stored</option></select></label><button data-action="primary" type="submit">Применить</button></form>
<div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><section class="media-library__grid" data-media-list aria-live="polite"></section>
<nav data-pages aria-label="Страницы медиатеки" hidden><button data-action="secondary" type="button" data-previous>Предыдущая</button><span data-page-number aria-live="polite"></span><button data-action="secondary" type="button" data-next>Следующая</button><button data-action="secondary" type="button" data-refresh>Обновить</button></nav>
</main>
<script>
const root = document.querySelector<HTMLElement>("main[data-api-url]");
const login = document.querySelector<HTMLFormElement>(".admin-login");
const filters = document.querySelector<HTMLFormElement>(".admin-queue-filters");
const list = document.querySelector<HTMLElement>("[data-media-list]");
const error = document.querySelector<HTMLElement>("[data-admin-error]");
const status = document.querySelector<HTMLElement>("[data-admin-status]");
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
const pages = document.querySelector<HTMLElement>("[data-pages]");
const previous = document.querySelector<HTMLButtonElement>("[data-previous]");
const next = document.querySelector<HTMLButtonElement>("[data-next]");
const refresh = document.querySelector<HTMLButtonElement>("[data-refresh]");
const pageNumber = document.querySelector<HTMLElement>("[data-page-number]");
let token = ""; let offset = 0; let timer: ReturnType<typeof setTimeout> | undefined;
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[char] ?? char));
const url = (value: unknown) => { try { const parsed = new URL(String(value), root?.dataset.apiUrl); return parsed.protocol === "http:" || parsed.protocol === "https:" ? esc(parsed.href) : ""; } catch { return ""; } };
const fail = (message: string) => { if (status) status.hidden = true; if (error) { error.textContent = message; error.hidden = false; } };
const succeed = (message: string) => { if (error) error.hidden = true; if (status) { status.textContent = message; status.hidden = false; } };
const endSession = (message?: string) => { token = ""; if (timer) clearTimeout(timer); timer = undefined; if (login) { login.hidden = false; login.reset(); } if (filters) filters.hidden = true; if (sessionBar) sessionBar.hidden = true; if (list) list.innerHTML = ""; pages?.setAttribute("hidden", ""); if (message) fail(message); };
const keepSession = () => { if (timer) clearTimeout(timer); timer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
const loading = () => '<div class="loading-card" aria-hidden="true"></div><span class="sr-only">Загружаем медиа</span>';
async function load() {
if (!root || !list || !filters) return;
error?.setAttribute("hidden", ""); list.innerHTML = loading(); list.setAttribute("aria-busy", "true");
const values = new FormData(filters); const params = new URLSearchParams({limit:"51", offset:String(offset)}); for (const key of ["entity_type", "status"]) { const value = String(values.get(key) || ""); if (value) params.set(key, value); }
const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/media/catalog?${params}`, {headers:{Authorization:`Bearer ${token}`} });
if (response.status === 401) { endSession(); throw new Error("Неверный или истёкший административный токен."); }
if (response.status === 429) { endSession(); throw new Error("Слишком много попыток. Повторите позже."); }
if (!response.ok) throw new Error("Не удалось загрузить медиатеку.");
const rows: Record<string, unknown>[] = await response.json(); keepSession(); list.removeAttribute("aria-busy");
if (!rows.length && offset > 0) { offset = 0; return load(); }
const assets = rows.slice(0, 50); if (pages) pages.hidden = !assets.length; if (previous) previous.disabled = offset === 0; if (next) next.disabled = rows.length <= 50; if (pageNumber) pageNumber.textContent = `Страница ${offset / 50 + 1}`;
if (!assets.length) { list.innerHTML = '<div class="state"><h2>Кандидатов нет</h2><p>Для выбранных фильтров нет approved или upgrade_queued файлов.</p></div>'; return; }
list.innerHTML = assets.map(asset => { const image = url(asset.image_url); const source = url(asset.source_url); const variants = (asset.derivatives as Record<string, unknown>[] ?? []).map(item => `${esc(item.format)} ${esc(item.width)}×${esc(item.height)}`).join(", "); return `<article class="media-library__card"><a href="${image}" target="_blank" rel="noreferrer">${image ? `<img src="${image}" alt="${esc(asset.label)}" loading="lazy" />` : ""}</a><h2>${esc(asset.label)}</h2><span>${esc(asset.status)} · ${esc(asset.entity_type)} · ${esc(asset.width)}×${esc(asset.height)}</span><p>${esc(asset.source_system)}${asset.duplicate_of ? ` · duplicate_of ${esc(asset.duplicate_of)}` : ""}</p>${variants ? `<small>Производные: ${variants}</small>` : "<small>Производных нет</small>"}${source ? `<a href="${source}" target="_blank" rel="noreferrer">Первоисточник →</a>` : ""}</article>`; }).join("");
}
login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); offset = 0; try { await load(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; if (filters) filters.hidden = false; } catch (cause) { list && (list.innerHTML = ""); fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
filters?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; try { await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка фильтрации."); } });
const move = async (delta: number) => { offset = Math.max(0, offset + delta); try { await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка загрузки страницы."); } };
previous?.addEventListener("click", () => move(-50)); next?.addEventListener("click", () => move(50)); refresh?.addEventListener("click", async () => { refresh.disabled = true; try { await load(); succeed("Медиатека обновлена."); } catch (cause) { fail(cause instanceof Error ? cause.message : "Не удалось обновить медиатеку."); } finally { refresh.disabled = false; } });
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
</script>
</Layout>
+22 -4
View File
@@ -9,6 +9,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
<p class="privacy">Токен хранится только в памяти страницы и не записывается в URL или localStorage.</p> <p class="privacy">Токен хранится только в памяти страницы и не записывается в URL или localStorage.</p>
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div> <div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div>
<div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><div class="moderation-list" data-moderation-list aria-live="polite"></div> <div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><div class="moderation-list" data-moderation-list aria-live="polite"></div>
<nav data-pages aria-label="Страницы очереди" hidden><button data-action="secondary" type="button" data-previous>Предыдущая</button><span data-page-number aria-live="polite"></span><button data-action="secondary" type="button" data-next>Следующая</button><button data-action="secondary" type="button" data-refresh>Обновить</button></nav>
<p class="admin-shortcuts"><kbd>A</kbd> одобрить карточку с фокусом · отклонение и удаление — только кнопками</p> <p class="admin-shortcuts"><kbd>A</kbd> одобрить карточку с фокусом · отклонение и удаление — только кнопками</p>
</section> </section>
<script> <script>
@@ -17,9 +18,15 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
const list = document.querySelector<HTMLElement>("[data-moderation-list]"); const list = document.querySelector<HTMLElement>("[data-moderation-list]");
const error = document.querySelector<HTMLElement>("[data-admin-error]"); const error = document.querySelector<HTMLElement>("[data-admin-error]");
const status = document.querySelector<HTMLElement>("[data-admin-status]"); const status = document.querySelector<HTMLElement>("[data-admin-status]");
const pages = document.querySelector<HTMLElement>("[data-pages]");
const previous = document.querySelector<HTMLButtonElement>("[data-previous]");
const next = document.querySelector<HTMLButtonElement>("[data-next]");
const refresh = document.querySelector<HTMLButtonElement>("[data-refresh]");
const pageNumber = document.querySelector<HTMLElement>("[data-page-number]");
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar"); const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]"); const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
let token = ""; let token = "";
let offset = 0;
let sessionTimer: ReturnType<typeof setTimeout> | undefined; let sessionTimer: ReturnType<typeof setTimeout> | undefined;
const loadingCards = () => `<div class="loading-grid" aria-hidden="true">${Array.from({length:2}, () => '<div class="loading-card"><span class="loading-line loading-line--label"></span><span class="loading-line loading-line--title"></span><span class="loading-line"></span><span class="loading-line loading-line--short"></span></div>').join("")}</div><span class="sr-only">Загружаем очередь модерации</span>`; const loadingCards = () => `<div class="loading-grid" aria-hidden="true">${Array.from({length:2}, () => '<div class="loading-card"><span class="loading-line loading-line--label"></span><span class="loading-line loading-line--title"></span><span class="loading-line"></span><span class="loading-line loading-line--short"></span></div>').join("")}</div><span class="sr-only">Загружаем очередь модерации</span>`;
const setLoading = (loading: boolean) => list?.setAttribute("aria-busy", String(loading)); const setLoading = (loading: boolean) => list?.setAttribute("aria-busy", String(loading));
@@ -32,16 +39,27 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
async function loadQueue() { async function loadQueue() {
if (!root || !list) return; if (!root || !list) return;
error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards(); error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards();
const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports?status=pending`, {headers:{Authorization:`Bearer ${token}`}}); const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports?status=pending&limit=51&offset=${offset}`, {headers:{Authorization:`Bearer ${token}`} });
if (response.status === 401) { endSession(); throw new Error("Неверный или истёкший административный токен."); } if (response.status === 401) { endSession(); throw new Error("Неверный или истёкший административный токен."); }
if (response.status === 429) { endSession(); throw new Error("Слишком много попыток. Повторите позже."); }
if (!response.ok) throw new Error("Не удалось загрузить очередь."); if (!response.ok) throw new Error("Не удалось загрузить очередь.");
const reports: Record<string, unknown>[] = await response.json(); const rows: Record<string, unknown>[] = await response.json();
keepSession(); keepSession();
setLoading(false); setLoading(false);
if (!rows.length && offset > 0) { offset = 0; return loadQueue(); }
const reports = rows.slice(0, 50);
if (pages) pages.hidden = !reports.length;
if (previous) previous.disabled = offset === 0;
if (next) next.disabled = rows.length <= 50;
if (pageNumber) pageNumber.textContent = `Страница ${offset / 50 + 1}`;
if (!reports.length) { list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Новых уловов для проверки нет.</p></div>'; return; } if (!reports.length) { list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Новых уловов для проверки нет.</p></div>'; return; }
list.innerHTML = reports.map(report => { const screenshotUrl = safeHttpUrl(report.screenshot_url); return `<article class="moderation-card" data-report-id="${esc(report.id)}" data-version="${esc(report.moderation_version)}"><div class="moderation-summary"><span class="activity-pill"><i></i>На проверке</span><h2>${esc(report.fish)}</h2><p>${esc(report.waterbody)} · ${esc(report.coordinates)}</p><dl><div><dt>Вес</dt><dd>${esc(report.weight_g)} г</dd></div><div><dt>Приманка</dt><dd>${esc(report.bait)}</dd></div><div><dt>Игрок</dt><dd>${esc(report.player_name)}</dd></div><div><dt>Отправлено</dt><dd>${esc(new Date(String(report.reported_at)).toLocaleString("ru-RU"))}</dd></div></dl>${report.comment ? `<blockquote>${esc(report.comment)}</blockquote>` : ""}</div><div class="moderation-proof">${screenshotUrl ? `<a href="${screenshotUrl}" target="_blank" rel="noreferrer"><img src="${screenshotUrl}" alt="Скриншот улова ${esc(report.fish)}" /></a>` : '<div class="no-proof">Скриншот не приложен</div>'}</div><div class="moderation-actions"><label>Причина решения<textarea rows="2" maxlength="1000"></textarea></label><div><button data-action="primary" type="button" data-decision="approved">Одобрить</button><button data-action="danger" type="button" data-decision="rejected">Отклонить</button><button data-action="quiet-danger" type="button" data-delete>Удалить</button></div></div></article>`; }).join(""); list.innerHTML = reports.map(report => { const screenshotUrl = safeHttpUrl(report.screenshot_url); return `<article class="moderation-card" data-report-id="${esc(report.id)}" data-version="${esc(report.moderation_version)}"><div class="moderation-summary"><span class="activity-pill"><i></i>На проверке</span><h2>${esc(report.fish)}</h2><p>${esc(report.waterbody)} · ${esc(report.coordinates)}</p><dl><div><dt>Вес</dt><dd>${esc(report.weight_g)} г</dd></div><div><dt>Приманка</dt><dd>${esc(report.bait)}</dd></div><div><dt>Игрок</dt><dd>${esc(report.player_name)}</dd></div><div><dt>Отправлено</dt><dd>${esc(new Date(String(report.reported_at)).toLocaleString("ru-RU"))}</dd></div></dl>${report.comment ? `<blockquote>${esc(report.comment)}</blockquote>` : ""}</div><div class="moderation-proof">${screenshotUrl ? `<a href="${screenshotUrl}" target="_blank" rel="noreferrer"><img src="${screenshotUrl}" alt="Скриншот улова ${esc(report.fish)}" /></a>` : '<div class="no-proof">Скриншот не приложен</div>'}</div><div class="moderation-actions"><label>Причина решения<textarea rows="2" maxlength="1000"></textarea></label><div><button data-action="primary" type="button" data-decision="approved">Одобрить</button><button data-action="danger" type="button" data-decision="rejected">Отклонить</button><button data-action="quiet-danger" type="button" data-delete>Удалить</button></div></div></article>`; }).join("");
} }
login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; } catch (cause) { setLoading(false); if (list) list.innerHTML = ""; fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } }); async function changePage(delta: number) { const oldOffset = offset; offset = Math.max(0, offset + delta); if (previous) previous.disabled = true; if (next) next.disabled = true; try { await loadQueue(); } catch { offset = oldOffset; setLoading(false); fail("Не удалось загрузить страницу очереди."); } }
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; } catch (cause) { setLoading(false); if (list) list.innerHTML = ""; fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели.")); logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
list?.addEventListener("click", async event => { list?.addEventListener("click", async event => {
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button[data-decision],button[data-delete]"); const card = button?.closest<HTMLElement>("[data-report-id]"); if (!button || !card || !root) return; const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button[data-decision],button[data-delete]"); const card = button?.closest<HTMLElement>("[data-report-id]"); if (!button || !card || !root) return;
@@ -49,7 +67,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
if (button.dataset.decision === "rejected" && !reason) { fail("Укажите причину отклонения."); card.querySelector("textarea")?.focus(); return; } if (button.dataset.decision === "rejected" && !reason) { fail("Укажите причину отклонения."); card.querySelector("textarea")?.focus(); return; }
if (button.hasAttribute("data-delete") && !window.confirm("Удалить и обезличить эту заявку? Действие нельзя отменить.")) return; if (button.hasAttribute("data-delete") && !window.confirm("Удалить и обезличить эту заявку? Действие нельзя отменить.")) return;
const cardButtons = card.querySelectorAll<HTMLButtonElement>("button"); cardButtons.forEach(item => item.disabled = true); const cardButtons = card.querySelectorAll<HTMLButtonElement>("button"); cardButtons.forEach(item => item.disabled = true);
try { const deleting = button.hasAttribute("data-delete"); const decision = deleting ? "Заявка удалена и обезличена." : button.dataset.decision === "approved" ? "Улов одобрен и опубликован." : "Улов отклонён."; const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports/${card.dataset.reportId}${deleting ? `?expected_version=${card.dataset.version}` : ""}`, {method:deleting ? "DELETE" : "PATCH",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:deleting ? undefined : JSON.stringify({status:button.dataset.decision,reason,expected_version:Number(card.dataset.version)})}); if (response.status === 401) { endSession(); throw new Error("Сессия истекла. Введите токен снова."); } if (response.status === 409) { await loadQueue(); throw new Error("Запись уже изменена в другой вкладке. Очередь обновлена."); } if (!response.ok) throw new Error("Не удалось сохранить решение."); keepSession(); card.remove(); succeed(decision); const nextAction = list.querySelector<HTMLButtonElement>("button[data-decision]"); if (nextAction) nextAction.focus(); else list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Все записи обработаны.</p></div>'; } catch (cause) { cardButtons.forEach(item => item.disabled = false); fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); } try { const deleting = button.hasAttribute("data-delete"); const decision = deleting ? "Заявка удалена и обезличена." : button.dataset.decision === "approved" ? "Улов одобрен и опубликован." : "Улов отклонён."; const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports/${card.dataset.reportId}${deleting ? `?expected_version=${card.dataset.version}` : ""}`, {method:deleting ? "DELETE" : "PATCH",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:deleting ? undefined : JSON.stringify({status:button.dataset.decision,reason,expected_version:Number(card.dataset.version)})}); 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("Не удалось сохранить решение."); keepSession(); card.remove(); succeed(decision); const nextAction = list.querySelector<HTMLButtonElement>("button[data-decision]"); if (nextAction) nextAction.focus(); else list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Все записи обработаны.</p></div>'; } catch (cause) { cardButtons.forEach(item => item.disabled = false); fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); }
}); });
document.addEventListener("keydown", event => { const target = event.target as HTMLElement; if (event.key.toLowerCase() !== "a" || target.matches("input,textarea,select") || event.ctrlKey || event.metaKey || event.altKey) return; const card = target.closest<HTMLElement>("[data-report-id]"); const approve = card?.querySelector<HTMLButtonElement>('[data-decision="approved"]'); if (approve && !approve.disabled) { event.preventDefault(); approve.click(); } }); document.addEventListener("keydown", event => { const target = event.target as HTMLElement; if (event.key.toLowerCase() !== "a" || target.matches("input,textarea,select") || event.ctrlKey || event.metaKey || event.altKey) return; const card = target.closest<HTMLElement>("[data-report-id]"); const approve = card?.querySelector<HTMLButtonElement>('[data-decision="approved"]'); if (approve && !approve.disabled) { event.preventDefault(); approve.click(); } });
</script> </script>
+1 -1
View File
@@ -18,7 +18,7 @@
@manifest path /site.webmanifest @manifest path /site.webmanifest
header @manifest Cache-Control "public, max-age=3600" header @manifest Cache-Control "public, max-age=3600"
@admin path /admin/* @admin path /admin /admin/*
handle @admin { handle @admin {
basic_auth { basic_auth {
{$ADMIN_BASIC_USER} {$ADMIN_BASIC_PASSWORD_HASH} {$ADMIN_BASIC_USER} {$ADMIN_BASIC_PASSWORD_HASH}
+2
View File
@@ -29,6 +29,8 @@ def probe(path, expected, authorization=None, method="GET", data=None):
assert isinstance(json.load(response), dict) assert isinstance(json.load(response), dict)
probe("/admin", 401)
probe("/admin", 200, basic)
probe("/admin/moderation", 401) probe("/admin/moderation", 401)
probe("/admin/moderation", 200, basic) probe("/admin/moderation", 200, basic)
probe("/admin/moderation", 401, "Bearer " + token) probe("/admin/moderation", 401, "Bearer " + token)
+389
View File
@@ -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
+47 -6
View File
@@ -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); их старые чекбоксы не являются текущей очередью. Этот файл — единственный актуальный список задач. Завершённые аудиты сохранены как история в [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] пакет восстановления 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] Astro check: 40 файлов, **0 errors / 0 warnings / 0 hints**; production build проходит;
- [x] API после миграции healthy; `apps/api/tests/test_api.py`: **20 passed**; - [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] изолированный production bootstrap проходит Caddy adapt, scheduler validation и Playwright-сценарий отправки/модерации без обращения к внешним источникам;
- [x] Astro + FastAPI + PostgreSQL остаются целевым стеком; Next.js и Vinext не используются. - [x] Astro + FastAPI + PostgreSQL остаются целевым стеком; Next.js и Vinext не используются.
@@ -38,14 +38,43 @@
- [x] **B16 · Самодостаточное хранение media dataset.** Все управляемые оригиналы из `data/media/files/` версионируются в Git вместе с manifest; локальный клон содержит полный исследовательский набор. Пользовательские скриншоты остаются в MinIO/S3, а наличие файла в Git не означает разрешение на публикацию без статуса `approved`. - [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-окном и отдельно утвердить новые файлы. - [ ] **B17 · Полный каталог рыб RF4DB — финальная загрузка.** Разрешённый RF4DB дал все 252 подписанных кандидата; 227 совпадений с RF4MAP сохранены как provenance-only `duplicate`, 15 уникальных файлов скачаны, а 10 URL (9 уникальных названий) остаются в очереди после корректно остановленного TLS-сбоя. Все поддомены RF4DB объединены одним 30-минутным cooldown. Завершить следующим разрешённым batch-окном и отдельно утвердить новые файлы.
- [ ] **B18 · Каталог водоёмов RF4DB.** После cooldown проиндексировать 19 русских карточек водоёмов, проверить полноразмерные карты и поставить только уникальные изображения в media queue. - [ ] **B18 · Каталог водоёмов RF4DB.** После cooldown проиндексировать 19 русских карточек водоёмов, проверить полноразмерные карты и поставить только уникальные изображения в media queue.
- [ ] **B19 · Каталог снастей RF4DB.** После водоёмов проиндексировать категории gear, построить crosswalk по типу, бренду, семейству и названию; не считать каталог полным до получения проверяемого общего счётчика. - [ ] **B19 · Каталог снастей RF4DB.** Подготовительный media-контур; полный справочник, crosswalk, связи с уловами и публичные страницы выполняются по пакету **G01G09** ниже. Не считать каталог полным до получения проверяемого общего счётчика по категориям.
- [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. Совпадение сущности больше нельзя считать достаточным основанием пропустить потенциально более качественный файл. - [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-минутные окна. Ошибка останавливает домен по существующим правилам; публикация старой версии при этом не прерывается. - [ ] **B21 · Очередь quality-upgrade.** Текущие approved-файлы не меняются до готовности замены. 15.09 отдельная очередь создана для 226 прямых RF4DB-альтернатив с сохранением `duplicate_of`; первая партия из 40 файлов загружена без ошибок в `upgrade_stored`, ещё 186 замен и 10 кандидатов недостающих рыб ожидают следующих разрешённых 30-минутных окон. Один provenance-only `duplicate`, не связанный с низкоразрешённым published-файлом, намеренно не переведён. Ошибка останавливает домен по существующим правилам; публикация старой версии при этом не прерывается.
- [ ] **B22 · Сравнение вариантов разных источников.** После загрузки для каждой рыбы сравнить реальные dimensions, размер файла, MIME, прозрачность, aspect ratio и визуальное соответствие подписи. Минимальный технический порог для основной карточки — 256 px по меньшей стороне, предпочтительный — 512 px; маленький файл сохранять только как fallback. Источник RF4DB/RF4MAP/официальный RF4 не получает автоматического приоритета: выбирается лучший прошедший проверку файл. - [ ] **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-файл в том же коммите, где включается новый. - [ ] **B23 · Безопасное продвижение и provenance.** Добавить связь `supersedes`/`replaced_by`, отдельное решение review и атомарное переключение публичного `entity_key`; сохранить обе исходные ссылки и возможность отката. На сайте показывать источник именно выбранного изображения, а в раскрываемом provenance — все проверенные варианты. Не удалять старый Git-файл в том же коммите, где включается новый.
- [ ] **B24 · Производные размеры.** После выбора оригиналов генерировать детерминированные WebP/AVIF thumbnails для каталога и отдельный крупный вариант для detail, фиксировать хэши производных в manifest и отдавать `srcset`. Это уберёт загрузку 1024×1024 на каждой маленькой карточке и исключит browser-upscale 48×48. - [ ] **B24 · Производные размеры.** После выбора оригиналов генерировать детерминированные WebP/AVIF thumbnails для каталога и отдельный крупный вариант для detail, фиксировать хэши производных в manifest и отдавать `srcset`. Это уберёт загрузку 1024×1024 на каждой маленькой карточке и исключит browser-upscale 48×48.
- [ ] **B25 · Визуальная приёмка media.** Собирать контактный лист «старое / кандидат / выбранное» с названием и источником; вручную проверить минимум все замены и репрезентативные desktop/mobile страницы в light/dark. Gate: нет битых файлов, искажённых пропорций, ложных соответствий, обрезанного объекта и изображений ниже 256 px без явной пометки «низкое разрешение». - [ ] **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, а не быть механической инверсией светлой. Реализовывать последовательно: сначала семантическая палитра и системный режим, затем ручное управление и полировка компонентов. Тёмная тема должна сохранять полевую эстетику RF4 Spotter, а не быть механической инверсией светлой.
@@ -91,6 +120,18 @@
- [x] **M05 · Защита от параллельных решений.** Обе очереди отдают `moderation_version`; mapping/publish/reject/approve/delete требуют увиденную версию и повторно сверяют её под row lock. Успешное решение атомарно увеличивает version, а устаревшая вкладка получает понятный `409` и автоматически перезагружает очередь. Схема обновляется линейной миграцией `0015`. - [x] **M05 · Защита от параллельных решений.** Обе очереди отдают `moderation_version`; mapping/publish/reject/approve/delete требуют увиденную версию и повторно сверяют её под row lock. Успешное решение атомарно увеличивает version, а устаревшая вкладка получает понятный `409` и автоматически перезагружает очередь. Схема обновляется линейной миграцией `0015`.
- [ ] **M06 · Персональные роли — после пилота.** Если модераторов станет больше одного, заменить общий токен индивидуальными аккаунтами, короткими сессиями, отзывом доступа и ролями; писать идентификатор оператора в аудит. Для одного владельца альфы не добавлять отдельный auth-сервис заранее. - [ ] **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. - [ ] Купить/подготовить Linux-сервер и подтвердить его публичный IPv4/IPv6.