sync
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s

This commit is contained in:
ik
2026-09-08 09:31:41 +07:00
parent 8b2d7e2e1c
commit ddb6909c4d
21 changed files with 255 additions and 58 deletions
+4 -4
View File
@@ -34,13 +34,13 @@ def activity_rows(
CatchReport.reported_at >= now - timedelta(hours=hours),
)
)
reports = list(session.scalars(query))
if waterbody:
reports = [r for r in reports if r.waterbody.slug == waterbody]
query = query.where(CatchReport.waterbody.has(slug=waterbody))
if fish:
reports = [r for r in reports if r.fish.slug == fish]
query = query.where(CatchReport.fish.has(slug=fish))
if method:
reports = [r for r in reports if r.fishing_method == method]
query = query.where(CatchReport.fishing_method == method)
reports = list(session.scalars(query))
groups: dict[tuple[object, object], list[CatchReport]] = {}
for report in reports:
+15 -1
View File
@@ -9,7 +9,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from .community_review import publish_observation
from .models import DataSource, ExternalEntityAlias, ExternalObservation
from .models import DataSource, ExternalEntityAlias, ExternalObservation, ModerationStatus
SOURCE_DEFAULTS = {
@@ -77,6 +77,18 @@ def stage_observations(
session.add(observation)
created += 1
else:
# Preserve the published snapshot, but withdraw it from activity until
# a moderator confirms the changed source record.
changed = any(getattr(observation, key) != values[key] for key in (
"source_url", "fish_name", "fish_external_id", "waterbody_name",
"waterbody_external_id", "x", "y", "weight_g",
)) or observation.payload != payload
if observation.status != "rejected" and changed and observation.catch_report is not None:
observation.catch_report.moderation_status = ModerationStatus.pending
observation.status = "staged"
observation.fish = None
observation.waterbody = None
observation.review_note = "Source record changed; manual mapping and publication required"
for key, value in values.items():
setattr(observation, key, value)
updated += 1
@@ -90,6 +102,8 @@ def stage_observations(
def _auto_publish(session: Session, observation: ExternalObservation) -> bool:
"""Publish only complete observations covered by previously reviewed aliases."""
if (
observation.catch_report_id is not None
or
observation.status not in {"staged", "mapped", "ready"}
or not observation.source.enabled
or observation.fish_external_id is None
+10 -2
View File
@@ -59,8 +59,10 @@ def reject_observation(session: Session, observation: ExternalObservation, *, re
def publish_observation(session: Session, observation: ExternalObservation) -> CatchReport:
if observation.catch_report is not None:
if observation.catch_report is not None and observation.status == "published":
return observation.catch_report
if observation.status == "rejected":
raise ExternalReviewError("rejected observation must be mapped again before publication")
if observation.fish is None or observation.waterbody is None or not _complete(observation):
raise ExternalReviewError("fish, waterbody, coordinates and weight are required for publication")
spot = session.scalar(select(Spot).where(
@@ -71,7 +73,7 @@ def publish_observation(session: Session, observation: ExternalObservation) -> C
session.add(spot)
bait = _bait(session, observation.payload.get("bait"))
now = datetime.now(timezone.utc)
report = CatchReport(
values = dict(
fish=observation.fish, waterbody=observation.waterbody, spot=spot, bait=bait,
weight_g=observation.weight_g,
fishing_method=observation.payload.get("fishing_method"),
@@ -95,6 +97,12 @@ def publish_observation(session: Session, observation: ExternalObservation) -> C
"original": observation.payload,
},
)
report = observation.catch_report
if report is None:
report = CatchReport(**values)
else:
for key, value in values.items():
setattr(report, key, value)
session.add(report)
session.flush()
observation.catch_report = report
+5 -2
View File
@@ -42,13 +42,15 @@ def run_source(source_system: str, *, now: datetime | None = None) -> bool:
source = session.get(DataSource, source_system)
if source is None or not source.enabled:
return False
# Lock before reading cooldown: committing the reservation makes it visible
# to the next contender before releasing this transaction lock.
if session.bind and session.bind.dialect.name == "postgresql" and not session.scalar(text("select pg_try_advisory_xact_lock(hashtext(:key))"), {"key": f"community:{source_system}"}):
return False
recent = list(session.scalars(select(CommunityImportRun).where(CommunityImportRun.source_system == source_system).order_by(CommunityImportRun.started_at.desc()).limit(8)))
latest = recent[0].started_at if recent else None
delay = retry_delay([run.status for run in recent])
if latest and (latest if latest.tzinfo else latest.replace(tzinfo=timezone.utc)) > current - timedelta(seconds=delay):
return False
if session.bind and session.bind.dialect.name == "postgresql" and not session.scalar(text("select pg_try_advisory_xact_lock(hashtext(:key))"), {"key": f"community:{source_system}"}):
return False
run = CommunityImportRun(source_system=source_system, source_url=url, started_at=current, status="running")
session.add(run); session.commit()
try:
@@ -57,6 +59,7 @@ def run_source(source_system: str, *, now: datetime | None = None) -> bool:
created, updated = stage_observations(session, [asdict(item) for item in records])
run.status, run.rows_seen, run.rows_created, run.rows_updated = "success", len(records), created, updated
except Exception as exc:
session.rollback()
run.status, run.error_summary = "failed", f"{type(exc).__name__}: {str(exc)[:500]}"
logger.exception("community import failed", extra={"event":"community_import_failed", "source_system":source_system})
run.finished_at = datetime.now(timezone.utc); session.commit()
+32 -2
View File
@@ -109,6 +109,17 @@ def baits(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0,
return list(db.scalars(select(Bait).order_by(Bait.name, Bait.id).offset(offset).limit(limit)))
@app.get("/api/v1/public-spot-pages")
def public_spot_pages(db: Db, limit: int = Query(500, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[str]:
rows = db.execute(select(Waterbody.slug, Spot.x, Spot.y, Fish.slug)
.select_from(CatchReport).join(Spot, CatchReport.spot_id == Spot.id)
.join(Waterbody, Spot.waterbody_id == Waterbody.id).join(Fish, CatchReport.fish_id == Fish.id)
.where(CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None))
.distinct().order_by(Waterbody.slug, Spot.x, Spot.y, Fish.slug).offset(offset).limit(limit))
return [path for water, x, y, fish in rows for path in
(f"/spots/{water}-{x}x{y}", f"/waterbodies/{water}/{fish}")]
@app.get("/api/v1/activity", response_model=list[ActivityOut])
def activity(
db: Db, response: Response, hours: int = Query(24),
@@ -119,7 +130,8 @@ def activity(
) -> list[ActivityOut]:
if hours not in {6, 12, 24, 72}:
raise HTTPException(status_code=422, detail="hours must be one of: 6, 12, 24, 72")
response.headers["Cache-Control"] = f"public, max-age={settings.public_cache_seconds}"
response.headers["Cache-Control"] = "no-store"
generation = public_cache.generation()
cache_key = ("activity", hours, waterbody, fish, method, sort, limit, offset)
cached = public_cache.get(cache_key, settings.public_cache_seconds)
if cached is not None:
@@ -133,7 +145,7 @@ def activity(
}
rows.sort(key=keys[sort], reverse=True)
response.headers["X-Cache"] = "MISS"
return public_cache.set(cache_key, rows[offset:offset + limit])
return public_cache.set(cache_key, rows[offset:offset + limit], generation=generation)
def _spot_or_404(db: Session, spot_id: UUID) -> Spot:
@@ -174,6 +186,24 @@ def spot_catches(spot_id: UUID, db: Db, limit: int = Query(50, ge=1, le=100), of
return [CatchOut(id=r.id, fish=r.fish.name_ru, weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, caught_at=r.caught_at, reported_at=r.reported_at, retrieve_method=r.retrieve_method, retrieve_speed=r.retrieve_speed, source_system=_report_source(r), source_url=r.source_url) for r in reports]
@app.get("/api/v1/spots/{spot_id}/timeline")
def spot_timeline(spot_id: UUID, db: Db) -> list[dict]:
_spot_or_404(db, spot_id)
now = datetime.now(timezone.utc)
buckets = []
for index in range(6):
start = now - timedelta(hours=(6 - index) * 12)
end = start + timedelta(hours=12)
count = db.scalar(select(func.count()).select_from(CatchReport).where(
CatchReport.spot_id == spot_id,
CatchReport.moderation_status == ModerationStatus.approved,
CatchReport.deleted_at.is_(None),
CatchReport.reported_at >= start, CatchReport.reported_at < end,
)) or 0
buckets.append({"start": start.isoformat(), "end": end.isoformat(), "count": count})
return buckets
def _report_source(report: CatchReport) -> str:
provenance = (report.raw_payload or {}).get("provenance", {})
if isinstance(provenance, dict) and provenance.get("source_system"):
+13 -2
View File
@@ -7,9 +7,15 @@ from typing import Any
class PublicResponseCache:
def __init__(self) -> None:
def __init__(self, max_entries: int = 128) -> None:
self._items: dict[tuple[Any, ...], tuple[float, Any]] = {}
self._lock = Lock()
self._max_entries = max_entries
self._generation = 0
def generation(self) -> int:
with self._lock:
return self._generation
def get(self, key: tuple[Any, ...], ttl_seconds: int) -> Any | None:
with self._lock:
@@ -19,13 +25,18 @@ class PublicResponseCache:
return None
return deepcopy(item[1])
def set(self, key: tuple[Any, ...], value: Any) -> Any:
def set(self, key: tuple[Any, ...], value: Any, *, generation: int | None = None) -> Any:
with self._lock:
if generation is not None and generation != self._generation:
return value
if key not in self._items and len(self._items) >= self._max_entries:
self._items.pop(next(iter(self._items)))
self._items[key] = (monotonic(), deepcopy(value))
return value
def invalidate(self) -> None:
with self._lock:
self._generation += 1
self._items.clear()
+31
View File
@@ -57,6 +57,37 @@ def test_invalid_period_is_rejected() -> None:
assert client.get("/api/v1/activity?sort=unknown").status_code == 422
def test_timeline_includes_more_than_catch_page_and_sitemap_includes_old_spots() -> None:
with Session(engine) as db:
fish = db.scalar(select(Fish).where(Fish.slug == "pike"))
water = db.scalar(select(Waterbody).where(Waterbody.slug == "test-lake"))
spot = Spot(waterbody=water, x=901, y=902)
db.add(spot)
db.flush()
spot_id = spot.id
reports = [CatchReport(fish=fish, waterbody=water, spot=spot, weight_g=1000,
reported_at=datetime.now(timezone.utc) - timedelta(hours=1 if i < 60 else 100),
source_type=SourceType.manual_import, source_confidence=70,
moderation_status=ModerationStatus.approved) for i in range(61)]
db.add_all(reports)
db.commit()
try:
result = client.get(f"/api/v1/spots/{spot_id}/timeline")
assert result.status_code == 200
assert sum(row["count"] for row in result.json()) == 60
assert len(client.get(f"/api/v1/spots/{spot_id}/catches").json()) == 50
for report in reports:
report.reported_at = datetime.now(timezone.utc) - timedelta(days=10)
db.commit()
assert "/spots/test-lake-901x902" in client.get("/api/v1/public-spot-pages").json()
finally:
for report in reports:
db.delete(report)
db.flush()
db.delete(spot)
db.commit()
def test_list_pagination_and_filter_validation() -> None:
assert client.get("/api/v1/fishes?limit=0").status_code == 422
assert client.get("/api/v1/fishes?limit=1&offset=0").status_code == 200
+28 -1
View File
@@ -8,7 +8,7 @@ from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session
from app.community_importer import CommunityImportError, stage_observations
from app.community_review import ExternalReviewError, map_observation, suggest_aliases
from app.community_review import ExternalReviewError, map_observation, publish_observation, suggest_aliases
from app.database import Base
from app.models import CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, Waterbody
from rf4_research.community_sources import parse_rf4db_catches, parse_rf4map_point, parse_rf4posts_spot
@@ -111,6 +111,33 @@ def test_complete_observation_with_reviewed_aliases_is_published(db: Session) ->
assert db.scalar(select(func.count()).select_from(CatchReport)) == 1
def test_changed_published_record_requires_review_and_reuses_report(db: Session) -> None:
fish = Fish(slug="pike", name_ru="Щука")
water = Waterbody(slug="test-lake", name_ru="Тестовое озеро")
db.add_all([fish, water])
db.commit()
stage_observations(db, [record() | {"weight_g": 5000}])
item = db.scalar(select(ExternalObservation))
map_observation(db, item, fish, water)
report = publish_observation(db, item)
report_id = report.id
stage_observations(db, [record() | {"weight_g": 6000}])
assert item.status == "staged"
assert report.moderation_status.value == "pending"
assert report.weight_g == 5000
assert item.weight_g == 6000
stage_observations(db, [record() | {"weight_g": 6000}])
assert item.status == "staged"
with pytest.raises(ExternalReviewError):
publish_observation(db, item)
map_observation(db, item, fish, water)
updated = publish_observation(db, item)
assert updated.id == report_id
assert updated.weight_g == 6000
assert updated.moderation_status.value == "approved"
assert db.scalar(select(func.count()).select_from(CatchReport)) == 1
def test_auto_publication_requires_enabled_source(db: Session) -> None:
source = DataSource(key="rf4db", name="RF4DB", base_url="https://rf4db.com", default_confidence=70, enabled=False)
fish = Fish(slug="pike", name_ru="Щука")
+12
View File
@@ -9,3 +9,15 @@ def test_cache_copies_values_and_invalidates() -> None:
assert cache.get(("activity",), 20) == [{"score": 10}]
cache.invalidate()
assert cache.get(("activity",), 20) is None
def test_cache_bounds_memory_and_rejects_result_started_before_invalidation() -> None:
cache = PublicResponseCache(max_entries=2)
generation = cache.generation()
cache.set((1,), [1])
cache.set((2,), [2])
cache.set((3,), [3])
assert cache.get((1,), 20) is None
cache.invalidate()
cache.set((4,), [4], generation=generation)
assert cache.get((4,), 20) is None
+1 -1
View File
@@ -16,7 +16,7 @@ const limited = item.catches < 3;
<FishSilhouette name={item.fish}/>
<div class="spot-meta"><span><FishingIcon name="pin" size={14}/> {item.x}:{item.y}</span><span><FishingIcon name="clock" size={14}/> {ago(item.last_confirmed_at)}</span></div>
<p class="data-note">{item.explanation}</p>
<DataPassport sources={item.sources} observedAt={item.last_confirmed_at} completeness={limited ? 75 : 100} confidence={item.confidence_score}/>
<DataPassport sources={item.sources} observedAt={item.last_confirmed_at} confidence={item.confidence_score}/>
<div class="bait-line"><FishingIcon name="lure" size={25}/><div><span>Работает сейчас</span><strong>{item.best_bait ?? "не указана"}</strong></div></div>
</div>
<div class="spot-stats"><div><strong>{item.catches}</strong><span>{plural(item.catches, ["улов", "улова", "уловов"])}</span></div><div><strong>{item.unique_players}</strong><span>{plural(item.unique_players, ["игрок", "игрока", "игроков"])}</span></div><div><strong>{kg(item.average_weight_g)}</strong><span>средний вес</span></div><div><strong>{item.confidence_score}%</strong><span>уверенность</span></div></div><span class="card-arrow"><FishingIcon name="arrow" size={22}/></span>
+4 -13
View File
@@ -1,19 +1,10 @@
---
import type { Catch } from "../lib/api";
const { catches } = Astro.props as { catches: Catch[] };
const now = Date.now();
const step = 12 * 60 * 60 * 1000;
const buckets = Array.from({ length: 6 }, (_, index) => ({ index, count: 0 }));
for (const item of catches) {
const timestamp = new Date(item.caught_at ?? item.reported_at).getTime();
const age = Math.floor((now - timestamp) / step);
if (age >= 0 && age < buckets.length) buckets[buckets.length - 1 - age].count += 1;
}
const { buckets } = Astro.props as { buckets: { start: string; end: string; count: number }[] };
const max = Math.max(1, ...buckets.map(bucket => bucket.count));
---
<section class="activity-timeline" aria-labelledby="timeline-title">
<header><div><span class="overline">Последние 72 часа</span><h2 id="timeline-title">Леска активности</h2></div><p>По показанным ниже уловам · шаг 12 часов</p></header>
<div class="timeline-chart" role="img" aria-label={`Распределение ${catches.length} показанных уловов за последние 72 часа`}>
{buckets.map((bucket, index) => <div class="timeline-slot"><span class="timeline-line" style={`--height:${Math.max(8, bucket.count / max * 100)}%`}><i></i></span><strong>{bucket.count}</strong><small>{index === 0 ? "72 ч" : index === 5 ? "сейчас" : `${(5-index)*12} ч`}</small></div>)}
<header><div><span class="overline">Последние 72 часа</span><h2 id="timeline-title">Леска активности</h2></div><p>Все одобренные записи · по времени поступления · шаг 12 часов</p></header>
<div class="timeline-chart">
{buckets.map((bucket, index) => <div class="timeline-slot"><span aria-hidden="true" class="timeline-line" style={`--height:${bucket.count / max * 100}%`}>{bucket.count > 0 && <i></i>}</span><strong>{bucket.count}</strong><small>{`${(6-index)*12}${(5-index)*12} ч назад`}</small></div>)}
</div>
</section>
+1 -1
View File
@@ -8,7 +8,7 @@ const { catches } = Astro.props as { catches: Catch[] };
const timestamp = item.caught_at ?? item.reported_at;
return <article>
<div><strong>{item.fish}</strong><span>{item.bait ?? "Приманка не указана"}</span><SourceBadge source={item.source_system} href={item.source_url}/></div>
<div><strong>{kg(item.weight_g)}</strong><span>{item.player_name ?? "Анонимно"}</span><time datetime={timestamp} title={new Date(timestamp).toLocaleString("ru-RU")}>{ago(timestamp)}</time></div>
<div><strong>{kg(item.weight_g)}</strong><span>{item.player_name ?? "Анонимно"}</span><span>{item.caught_at ? "Время улова" : "Получено · время улова неизвестно"}</span><time datetime={timestamp}>{ago(timestamp)} · {new Date(timestamp).toLocaleString("ru-RU", { timeZone: "UTC" })} UTC</time></div>
</article>;
})}
</div>
+3 -3
View File
@@ -1,10 +1,10 @@
---
import SourceBadge from "./SourceBadge.astro";
import { ago } from "../lib/api";
type Props = { sources: string[]; sourceUrl?: string | null; observedAt: string; completeness: number; confidence?: number | null; status?: "verified" | "unverified" | "incomplete" };
const { sources, sourceUrl, observedAt, completeness, confidence = null, status = "verified" } = Astro.props;
type Props = { sources: string[]; sourceUrl?: string | null; observedAt: string; completeness?: number | null; confidence?: number | null; status?: "verified" | "unverified" | "incomplete" };
const { sources, sourceUrl, observedAt, completeness = null, confidence = null, status = "verified" } = Astro.props;
const statusLabels = { verified: "Учтено", unverified: "Ждёт проверки", incomplete: "Неполные данные" };
const completenessLabel = completeness >= 100 ? "Полные" : `${Math.max(0, completeness)}% полей`;
const completenessLabel = completeness == null ? "Не рассчитана" : `${Math.min(100, Math.max(0, completeness))}% полей`;
---
<section class="data-passport" aria-label="Паспорт данных">
<header><span>Паспорт данных</span><strong data-passport-status={status}>{statusLabels[status]}</strong></header>
+2 -1
View File
@@ -3,7 +3,8 @@ import SourceBadge from "./SourceBadge.astro";
import { ago, kg, type PublicObservation } from "../lib/api";
const { signals } = Astro.props as { signals: PublicObservation[] };
const groups = [...signals.reduce((map, signal) => {
const key = [signal.fish_name, signal.waterbody_name, signal.x, signal.y, signal.weight_g].join("|").toLocaleLowerCase("ru");
// Similar fields do not establish that two sources describe the same catch.
const key = JSON.stringify([signal.source_system, signal.id]);
const current = map.get(key);
if (!current) map.set(key, { ...signal, observations: [signal] });
else {
+5 -1
View File
@@ -20,8 +20,12 @@ export { activityLevel, ago, kg, plural } from "./presentation";
const base = process.env.API_INTERNAL_URL || import.meta.env.API_INTERNAL_URL || "http://localhost:8000";
export class ApiError extends Error {
constructor(public status: number) { super(`API ${status}`); }
}
export async function api<T>(path: string): Promise<T> {
const response = await fetch(`${base}${path}`);
if (!response.ok) throw new Error(`API ${response.status}`);
if (!response.ok) throw new ApiError(response.status);
return response.json() as Promise<T>;
}
@@ -25,9 +25,21 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
const rows = await json(`${root.dataset.apiUrl}/api/v1/admin/external-observations?limit=200`, {headers:{Authorization:`Bearer ${token}`}});
const pending = rows.filter((row: Record<string, unknown>) => !["published", "rejected"].includes(String(row.status)));
if (!pending.length) { list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Все внешние записи обработаны.</p></div>'; return; }
list.innerHTML = pending.map((row: Record<string, unknown>) => { const complete = row.x != null && row.y != null && row.weight_g != null; return `<article class="moderation-card" data-observation-id="${esc(row.id)}"><div class="moderation-summary"><span class="activity-pill"><i></i>${esc(row.source_system)} · ${esc(row.status)}</span><h2>${esc(row.fish_name)}</h2><p>${esc(row.waterbody_name)}</p><dl><div><dt>Координаты</dt><dd>${row.x == null || row.y == null ? "нет" : `${esc(row.x)}:${esc(row.y)}`}</dd></div><div><dt>Вес</dt><dd>${row.weight_g == null ? "нет" : `${esc(row.weight_g)} г`}</dd></div><div><dt>ID источника</dt><dd>${esc(row.source_external_id)}</dd></div><div><dt>Состояние</dt><dd>${complete ? "полная запись" : "неполная"}</dd></div></dl><a href="${esc(row.source_url)}" target="_blank" rel="noreferrer">Открыть первоисточник</a></div><div class="moderation-actions"><label>Каноническая рыба<select name="fish" required><option value="">Выберите…</option>${options(fishes, row.fish_slug)}</select></label><label>Канонический водоём<select name="waterbody" required><option value="">Выберите…</option>${options(waters, row.waterbody_slug)}</select></label><label>Примечание<textarea name="note" rows="2" maxlength="1000">${esc(row.review_note ?? "")}</textarea></label><div><button type="button" data-map>Сопоставить</button><button type="button" data-publish ${complete && row.status === "ready" ? "" : "disabled"}>Опубликовать</button><button class="reject" type="button" data-reject>Отклонить</button></div></div></article>`; }).join("");
list.innerHTML = pending.map((row: Record<string, unknown>) => { const complete = row.x != null && row.y != null && row.weight_g != null; return `<article class="moderation-card" data-observation-id="${esc(row.id)}"><div class="moderation-summary"><span class="activity-pill"><i></i>${esc(row.source_system)} · ${esc(row.status)}</span><h2>${esc(row.fish_name)}</h2><p>${esc(row.waterbody_name)}</p><dl><div><dt>Координаты</dt><dd>${row.x == null || row.y == null ? "нет" : `${esc(row.x)}:${esc(row.y)}`}</dd></div><div><dt>Вес</dt><dd>${row.weight_g == null ? "нет" : `${esc(row.weight_g)} г`}</dd></div><div><dt>ID источника</dt><dd>${esc(row.source_external_id)}</dd></div><div><dt>Состояние</dt><dd>${complete ? "полная запись" : "неполная"}</dd></div></dl><a href="${esc(row.source_url)}" target="_blank" rel="noreferrer">Открыть первоисточник</a></div><div class="moderation-actions"><label>Каноническая рыба<select name="fish" required><option value="">Выберите…</option>${options(fishes, row.fish_slug)}</select></label><label>Канонический водоём<select name="waterbody" required><option value="">Выберите…</option>${options(waters, row.waterbody_slug)}</select></label><label>Примечание<textarea name="note" rows="2" maxlength="1000">${esc(row.review_note ?? "")}</textarea></label><p data-alias-message role="status"></p><div><button type="button" data-suggest>Подсказать соответствия</button><button type="button" data-map>Сопоставить</button><button type="button" data-publish ${complete && row.status === "ready" ? "" : "disabled"}>Опубликовать</button><button class="reject" type="button" data-reject>Отклонить</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; } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
list?.addEventListener("click", async event => { const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button"); const card = button?.closest<HTMLElement>("[data-observation-id]"); if (!button || !card || !root) return; button.disabled = true; const base = `${root.dataset.apiUrl}/api/v1/admin/external-observations/${card.dataset.observationId}`; const headers = {Authorization:`Bearer ${token}`,"Content-Type":"application/json"}; try { if (button.hasAttribute("data-map")) { const fish_slug = card.querySelector<HTMLSelectElement>('[name="fish"]')?.value; const waterbody_slug = card.querySelector<HTMLSelectElement>('[name="waterbody"]')?.value; if (!fish_slug || !waterbody_slug) throw new Error("Выберите рыбу и водоём."); await json(`${base}/mapping`, {method:"PATCH",headers,body:JSON.stringify({fish_slug,waterbody_slug,note:card.querySelector<HTMLTextAreaElement>('[name="note"]')?.value || null})}); } else if (button.hasAttribute("data-publish")) { await json(`${base}/publish`, {method:"POST",headers}); } else if (button.hasAttribute("data-reject")) { const reason = card.querySelector<HTMLTextAreaElement>('[name="note"]')?.value.trim(); if (!reason) throw new Error("Укажите причину отклонения."); await json(`${base}/reject`, {method:"PATCH",headers,body:JSON.stringify({reason})}); } await loadQueue(); } catch (cause) { button.disabled = false; fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); } });
list?.addEventListener("click", async event => { const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button"); const card = button?.closest<HTMLElement>("[data-observation-id]"); if (!button || !card || !root) return; button.disabled = true; const base = `${root.dataset.apiUrl}/api/v1/admin/external-observations/${card.dataset.observationId}`; const headers = {Authorization:`Bearer ${token}`,"Content-Type":"application/json"}; try {
if (button.hasAttribute("data-suggest")) {
const suggestion = await json(`${base}/alias-suggestions`, {headers});
const fish = fishes.find(item => item.slug === suggestion.fish_slug);
const water = waters.find(item => item.slug === suggestion.waterbody_slug);
const message = card.querySelector<HTMLElement>("[data-alias-message]");
if (message) message.textContent = fish || water
? `Ранее подтверждено: рыба — ${fish?.name_ru ?? "нет соответствия"}, водоём — ${water?.name_ru ?? "нет соответствия"}. Проверьте и выберите значения перед сопоставлением.`
: "Для этого источника подтверждённых соответствий пока нет.";
button.disabled = false;
return;
}
if (button.hasAttribute("data-map")) { const fish_slug = card.querySelector<HTMLSelectElement>('[name="fish"]')?.value; const waterbody_slug = card.querySelector<HTMLSelectElement>('[name="waterbody"]')?.value; if (!fish_slug || !waterbody_slug) throw new Error("Выберите рыбу и водоём."); await json(`${base}/mapping`, {method:"PATCH",headers,body:JSON.stringify({fish_slug,waterbody_slug,note:card.querySelector<HTMLTextAreaElement>('[name="note"]')?.value || null})}); } else if (button.hasAttribute("data-publish")) { await json(`${base}/publish`, {method:"POST",headers}); } else if (button.hasAttribute("data-reject")) { const reason = card.querySelector<HTMLTextAreaElement>('[name="note"]')?.value.trim(); if (!reason) throw new Error("Укажите причину отклонения."); await json(`${base}/reject`, {method:"PATCH",headers,body:JSON.stringify({reason})}); } await loadQueue(); } catch (cause) { button.disabled = false; fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); } });
</script>
</Layout>
+26 -15
View File
@@ -1,23 +1,34 @@
import type { APIRoute } from "astro";
import { api, spotPath, type Activity, type DictionaryItem } from "../lib/api";
import { api, type DictionaryItem } from "../lib/api";
const escapeXml = (value: string) => value.replace(/[<>&'\"]/g, character => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", "'": "&apos;", '"': "&quot;" })[character] ?? character);
const escapeXml = (value: string) => value.replace(/[<>&'"]/g, c => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", "'": "&apos;", '"': "&quot;" })[c] ?? c);
let lastGood: { origin: string; xml: string; at: number } | undefined;
export const GET: APIRoute = async ({ site }) => {
const origin = site?.origin ?? import.meta.env.PUBLIC_SITE_URL ?? "https://rf4spotter.ru";
const paths = new Set(["/", "/records", "/report", "/status", "/rules", "/privacy"]);
const origin = site?.origin ?? "https://rf4spotter.ru";
const output = (xml: string) => new Response(xml, { headers: { "Content-Type": "application/xml; charset=utf-8", "Cache-Control": "public, max-age=1800" } });
if (lastGood?.origin === origin && Date.now() - lastGood.at < 1800000) return output(lastGood.xml);
try {
const [activity, fishes, waters] = await Promise.all([api<Activity[]>("/api/v1/activity?hours=72&limit=100"), api<DictionaryItem[]>("/api/v1/fishes?limit=500"), api<DictionaryItem[]>("/api/v1/waterbodies?limit=500")]);
paths.add("/fish"); paths.add("/waterbodies");
fishes.forEach(item => paths.add(`/fish/${item.slug}`));
waters.forEach(item => paths.add(`/waterbodies/${item.slug}`));
activity.forEach(item => paths.add(spotPath(item)));
activity.forEach(item => paths.add(`/waterbodies/${item.waterbody_slug}/${item.fish_slug}`));
const paths = new Set(["/", "/records", "/report", "/status", "/rules", "/privacy", "/fish", "/waterbodies"]);
for (const [endpoint, prefix] of [["fishes", "fish"], ["waterbodies", "waterbodies"]]) {
for (let offset = 0; ; offset += 500) {
const rows = await api<DictionaryItem[]>(`/api/v1/${endpoint}?limit=500&offset=${offset}`);
rows.forEach(row => paths.add(`/${prefix}/${row.slug}`));
if (paths.size > 49000) throw new Error("Sitemap index required");
if (rows.length < 500) break;
}
}
for (let offset = 0; ; offset += 500) {
const rows = await api<string[]>(`/api/v1/public-spot-pages?limit=500&offset=${offset}`);
rows.forEach(path => paths.add(path));
if (paths.size > 49000) throw new Error("Sitemap index required");
if (rows.length < 1000) break;
}
const xml = `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${[...paths].map(path => `<url><loc>${escapeXml(new URL(path, origin).toString())}</loc></url>`).join("")}</urlset>`;
lastGood = { origin, xml, at: Date.now() };
return output(xml);
} catch {
// A temporary API outage must not make the static part of the sitemap unavailable.
if (lastGood?.origin === origin) return output(lastGood.xml);
return new Response("Sitemap temporarily unavailable", { status: 503, headers: { "Retry-After": "60", "Cache-Control": "no-store" } });
}
const urls = [...paths].map(path => `<url><loc>${escapeXml(new URL(path, origin).toString())}</loc></url>`).join("");
return new Response(`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}</urlset>`, {
headers: { "Content-Type": "application/xml; charset=utf-8", "Cache-Control": "public, max-age=1800" },
});
};
+9 -3
View File
@@ -4,9 +4,10 @@ import SourceBadge from "../../components/SourceBadge.astro";
import CoordinateRadar from "../../components/CoordinateRadar.astro";
import ActivityTimeline from "../../components/ActivityTimeline.astro";
import CatchList from "../../components/CatchList.astro";
import { activityLevel, api, plural, type Activity, type Catch, type Spot } from "../../lib/api";
import { activityLevel, api, ApiError, plural, type Activity, type Catch, type Spot } from "../../lib/api";
const { id } = Astro.params;
let spot: Spot | null = null, catches: Catch[] = [], activity: Activity | null = null, unavailable = false;
let timeline: { start: string; end: string; count: number }[] = [];
try {
const readable = id?.match(/^(.+)-(-?\d+)x(-?\d+)$/);
const spotResult = readable
@@ -15,7 +16,12 @@ try {
if (!readable) return Astro.redirect(`/spots/${spotResult.waterbody_slug}-${spotResult.x}x${spotResult.y}`, 301);
const [catchResult, activityRows] = await Promise.all([api<Catch[]>(`/api/v1/spots/${spotResult.id}/catches`), api<Activity[]>("/api/v1/activity?hours=24&limit=100")]);
spot = spotResult; catches = catchResult; activity = activityRows.find(item => item.spot_id === spotResult.id) ?? null;
} catch { unavailable = true; }
timeline = await api<typeof timeline>(`/api/v1/spots/${spotResult.id}/timeline`);
} catch (error) {
unavailable = true;
Astro.response.status = error instanceof ApiError && [404, 422].includes(error.status) ? 404 : 503;
if (Astro.response.status === 503) Astro.response.headers.set("Retry-After", "60");
}
const level = activity ? activityLevel(activity.activity_score) : null;
const spotDescription = spot ? `Свежие уловы и активность на точке ${spot.x}:${spot.y}, ${spot.waterbody}: рыба, вес, приманки и источники данных.` : "Данные точки ловли Russian Fishing 4.";
const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: [
@@ -28,7 +34,7 @@ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "Breadcr
{unavailable || !spot ? <div class="state"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <>
<section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p></div><CoordinateRadar x={spot.x} y={spot.y}/></section>
<div class="periods"><div><strong>{spot.catches_24h}</strong><span>за 24 часа</span></div><div><strong>{spot.catches_3d}</strong><span>за 3 дня</span></div><div><strong>{spot.catches_7d}</strong><span>за 7 дней</span></div></div>
<ActivityTimeline catches={catches}/>
<ActivityTimeline buckets={timeline}/>
<section class="detail-grid">
<div>
<div class="section-heading"><h2>Последние уловы</h2></div>