feat: lock concurrent moderation decisions
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s

This commit is contained in:
ik
2026-09-13 09:26:18 +07:00
parent cbd854d933
commit 0abe1a2bb4
10 changed files with 147 additions and 31 deletions
+2
View File
@@ -264,6 +264,8 @@ curl -H "Authorization: Bearer change-me-in-production" \
`GET /api/v1/admin/moderation-history` объединяет историю решений по пользовательским уловам и внешним наблюдениям; последние события видны на dashboard. Ответ содержит только тип и UUID сущности, время, действие, оператора и причину — без ников, исходных URL и parser payload. Dashboard выгружает отдельный `moderation-history-export`: в нём дополнительно исключены UUID, оператор и свободный текст причины, остаются только время, тип, действие и признак необходимости подтверждения.
Решения в обеих очередях используют optimistic locking: API возвращает `moderation_version`, а изменяющий запрос обязан прислать увиденное значение. Проверка выполняется под блокировкой строки; если другая вкладка уже решила запись, сервер отвечает `409`, UI обновляет очередь и не перезаписывает более новое решение.
## Эксплуатация production
- первый запуск, обновление и preflight: [deploy/README.md](deploy/README.md);
@@ -0,0 +1,22 @@
"""add optimistic moderation versions
Revision ID: 0015
Revises: 0014
"""
from alembic import op
import sqlalchemy as sa
revision = "0015"
down_revision = "0014"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("catch_report", sa.Column("moderation_version", sa.Integer(), nullable=False, server_default="0"))
op.add_column("external_observation", sa.Column("moderation_version", sa.Integer(), nullable=False, server_default="0"))
def downgrade() -> None:
op.drop_column("external_observation", "moderation_version")
op.drop_column("catch_report", "moderation_version")
+25 -9
View File
@@ -33,7 +33,7 @@ from .routers.public_data import router as public_data_router
from .routers.submissions import router as submissions_router
from .time_utils import aware
from .public_cache import public_cache
from .schemas import ActivityOut, AdminCatchReportOut, AdminModerationHistoryOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationUpdate
from .schemas import ActivityOut, AdminCatchReportOut, AdminModerationHistoryOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationAction, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationUpdate
from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot
from .submission_security import check_rate_limit
from .submission_security import is_trusted_proxy as _is_trusted_proxy
@@ -237,6 +237,7 @@ def _external_out(item: ExternalObservation) -> ExternalObservationOut:
waterbody_slug=item.waterbody.slug if item.waterbody else None,
catch_report_id=item.catch_report_id, review_note=item.review_note,
missing_fields=missing_fields, source_payload=allowed_payload,
moderation_version=item.moderation_version,
)
@@ -312,13 +313,16 @@ def admin_map_external_observation(
observation_id: UUID, payload: ExternalObservationMapping, db: Db,
_: Annotated[str, Depends(_admin)],
) -> ExternalObservationOut:
observation = db.get(ExternalObservation, observation_id)
observation = db.scalar(select(ExternalObservation).where(ExternalObservation.id == observation_id).with_for_update())
fish = db.scalar(select(Fish).where(Fish.slug == payload.fish_slug))
waterbody = db.scalar(select(Waterbody).where(Waterbody.slug == payload.waterbody_slug))
if observation is None:
raise HTTPException(status_code=404, detail="external observation not found")
if fish is None or waterbody is None:
raise HTTPException(status_code=422, detail="unknown fish or waterbody")
if observation.moderation_version != payload.expected_version:
raise HTTPException(status_code=409, detail="observation changed; reload the queue")
observation.moderation_version += 1
try:
return _external_out(map_observation(db, observation, fish, waterbody, note=payload.note))
except ExternalReviewError as exc:
@@ -327,11 +331,14 @@ def admin_map_external_observation(
@app.post("/api/v1/admin/external-observations/{observation_id}/publish", response_model=ExternalObservationPublished)
def admin_publish_external_observation(
observation_id: UUID, db: Db, _: Annotated[str, Depends(_admin)],
observation_id: UUID, payload: ExternalObservationAction, db: Db, _: Annotated[str, Depends(_admin)],
) -> ExternalObservationPublished:
observation = db.get(ExternalObservation, observation_id)
observation = db.scalar(select(ExternalObservation).where(ExternalObservation.id == observation_id).with_for_update())
if observation is None:
raise HTTPException(status_code=404, detail="external observation not found")
if observation.moderation_version != payload.expected_version:
raise HTTPException(status_code=409, detail="observation changed; reload the queue")
observation.moderation_version += 1
try:
report = publish_observation(db, observation)
except ExternalReviewError as exc:
@@ -345,9 +352,12 @@ def admin_reject_external_observation(
observation_id: UUID, payload: ExternalObservationDecision, db: Db,
_: Annotated[str, Depends(_admin)],
) -> ExternalObservationOut:
observation = db.get(ExternalObservation, observation_id)
observation = db.scalar(select(ExternalObservation).where(ExternalObservation.id == observation_id).with_for_update())
if observation is None:
raise HTTPException(status_code=404, detail="external observation not found")
if observation.moderation_version != payload.expected_version:
raise HTTPException(status_code=409, detail="observation changed; reload the queue")
observation.moderation_version += 1
try:
return _external_out(reject_observation(db, observation, reason=payload.reason))
except ExternalReviewError as exc:
@@ -459,16 +469,19 @@ app.include_router(submissions_router)
@app.get("/api/v1/admin/catch-reports", response_model=list[AdminCatchReportOut])
def admin_reports(db: Db, _: Annotated[str, Depends(_admin)], status: ModerationStatus = ModerationStatus.pending, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[AdminCatchReportOut]:
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.waterbody), joinedload(CatchReport.spot), joinedload(CatchReport.bait)).where(CatchReport.source_type == SourceType.user, CatchReport.moderation_status == status, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at, CatchReport.id).offset(offset).limit(limit)))
return [AdminCatchReportOut(id=r.id, fish=r.fish.name_ru, waterbody=r.waterbody.name_ru, coordinates=f"{r.spot.x}:{r.spot.y}" if r.spot else "", weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, reported_at=r.reported_at, moderation_status=r.moderation_status.value, comment=(r.raw_payload or {}).get("comment"), screenshot_url=signed_screenshot_url(r.screenshot_key) if r.screenshot_key else None) for r in reports]
return [AdminCatchReportOut(id=r.id, fish=r.fish.name_ru, waterbody=r.waterbody.name_ru, coordinates=f"{r.spot.x}:{r.spot.y}" if r.spot else "", weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, reported_at=r.reported_at, moderation_status=r.moderation_status.value, comment=(r.raw_payload or {}).get("comment"), screenshot_url=signed_screenshot_url(r.screenshot_key) if r.screenshot_key else None, moderation_version=r.moderation_version) for r in reports]
@app.patch("/api/v1/admin/catch-reports/{report_id}", response_model=CatchReportCreated)
def moderate_report(report_id: UUID, payload: ModerationUpdate, db: Db, moderator: Annotated[str, Depends(_admin)]) -> CatchReportCreated:
report = db.get(CatchReport, report_id)
report = db.scalar(select(CatchReport).where(CatchReport.id == report_id).with_for_update())
if report is None or report.source_type != SourceType.user or report.deleted_at is not None:
raise HTTPException(status_code=404, detail="catch report not found")
if report.moderation_version != payload.expected_version:
raise HTTPException(status_code=409, detail="report changed; reload the queue")
previous = report.moderation_status
report.moderation_status = ModerationStatus(payload.status)
report.moderation_version += 1
db.add(ModerationEvent(catch_report=report, created_at=datetime.now(timezone.utc), previous_status=previous, new_status=report.moderation_status, moderator=moderator, reason=payload.reason))
db.commit()
public_cache.invalidate()
@@ -476,10 +489,12 @@ def moderate_report(report_id: UUID, payload: ModerationUpdate, db: Db, moderato
@app.delete("/api/v1/admin/catch-reports/{report_id}", status_code=204, response_class=Response)
def delete_report(report_id: UUID, db: Db, moderator: Annotated[str, Depends(_admin)]) -> Response:
report = db.get(CatchReport, report_id)
def delete_report(report_id: UUID, db: Db, moderator: Annotated[str, Depends(_admin)], expected_version: int = Query(ge=0)) -> Response:
report = db.scalar(select(CatchReport).where(CatchReport.id == report_id).with_for_update())
if report is None or report.source_type != SourceType.user or report.deleted_at is not None:
raise HTTPException(status_code=404, detail="catch report not found")
if report.moderation_version != expected_version:
raise HTTPException(status_code=409, detail="report changed; reload the queue")
previous = report.moderation_status
if report.screenshot_key:
try:
@@ -487,6 +502,7 @@ def delete_report(report_id: UUID, db: Db, moderator: Annotated[str, Depends(_ad
except Exception as exc:
raise HTTPException(status_code=502, detail="screenshot deletion failed") from exc
report.moderation_status = ModerationStatus.rejected
report.moderation_version += 1
report.deleted_at = datetime.now(timezone.utc)
report.player_name = None
report.source_url = None
+2
View File
@@ -95,6 +95,7 @@ class CatchReport(Base):
screenshot_upload_token_hash: Mapped[str | None] = mapped_column(String(64))
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
raw_payload: Mapped[dict | None] = mapped_column(JSON)
moderation_version: Mapped[int] = mapped_column(default=0)
fish: Mapped[Fish] = relationship()
spot: Mapped[Spot | None] = relationship()
waterbody: Mapped[Waterbody] = relationship()
@@ -197,6 +198,7 @@ class ExternalObservation(Base):
catch_report_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("catch_report.id"), unique=True)
review_note: Mapped[str | None] = mapped_column(Text)
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
moderation_version: Mapped[int] = mapped_column(default=0)
source: Mapped[DataSource] = relationship()
fish: Mapped[Fish | None] = relationship()
waterbody: Mapped[Waterbody | None] = relationship()
+9
View File
@@ -199,11 +199,13 @@ class AdminCatchReportOut(BaseModel):
moderation_status: str
comment: str | None
screenshot_url: str | None
moderation_version: int
class ModerationUpdate(BaseModel):
status: str
reason: str | None = Field(default=None, max_length=1000)
expected_version: int = Field(ge=0)
@field_validator("status")
@classmethod
@@ -236,12 +238,14 @@ class ExternalObservationOut(BaseModel):
review_note: str | None
missing_fields: list[str]
source_payload: dict[str, str | int | float | bool | None]
moderation_version: int
class ExternalObservationMapping(BaseModel):
fish_slug: str
waterbody_slug: str
note: str | None = Field(default=None, max_length=1000)
expected_version: int = Field(ge=0)
class ExternalAliasSuggestionOut(BaseModel):
@@ -261,6 +265,11 @@ class AdminModerationHistoryOut(BaseModel):
class ExternalObservationDecision(BaseModel):
reason: str = Field(min_length=1, max_length=1000)
expected_version: int = Field(ge=0)
class ExternalObservationAction(BaseModel):
expected_version: int = Field(ge=0)
class ExternalObservationPublished(BaseModel):
+67 -5
View File
@@ -147,6 +147,10 @@
"title": "Moderation Status",
"type": "string"
},
"moderation_version": {
"title": "Moderation Version",
"type": "integer"
},
"player_name": {
"anyOf": [
{
@@ -194,7 +198,8 @@
"reported_at",
"moderation_status",
"comment",
"screenshot_url"
"screenshot_url",
"moderation_version"
],
"title": "AdminCatchReportOut",
"type": "object"
@@ -643,8 +648,27 @@
"title": "ExternalAliasSuggestionOut",
"type": "object"
},
"ExternalObservationAction": {
"properties": {
"expected_version": {
"minimum": 0.0,
"title": "Expected Version",
"type": "integer"
}
},
"required": [
"expected_version"
],
"title": "ExternalObservationAction",
"type": "object"
},
"ExternalObservationDecision": {
"properties": {
"expected_version": {
"minimum": 0.0,
"title": "Expected Version",
"type": "integer"
},
"reason": {
"maxLength": 1000,
"minLength": 1,
@@ -653,13 +677,19 @@
}
},
"required": [
"reason"
"reason",
"expected_version"
],
"title": "ExternalObservationDecision",
"type": "object"
},
"ExternalObservationMapping": {
"properties": {
"expected_version": {
"minimum": 0.0,
"title": "Expected Version",
"type": "integer"
},
"fish_slug": {
"title": "Fish Slug",
"type": "string"
@@ -683,7 +713,8 @@
},
"required": [
"fish_slug",
"waterbody_slug"
"waterbody_slug",
"expected_version"
],
"title": "ExternalObservationMapping",
"type": "object"
@@ -750,6 +781,10 @@
"title": "Missing Fields",
"type": "array"
},
"moderation_version": {
"title": "Moderation Version",
"type": "integer"
},
"published_at": {
"anyOf": [
{
@@ -906,7 +941,8 @@
"catch_report_id",
"review_note",
"missing_fields",
"source_payload"
"source_payload",
"moderation_version"
],
"title": "ExternalObservationOut",
"type": "object"
@@ -1189,6 +1225,11 @@
},
"ModerationUpdate": {
"properties": {
"expected_version": {
"minimum": 0.0,
"title": "Expected Version",
"type": "integer"
},
"reason": {
"anyOf": [
{
@@ -1207,7 +1248,8 @@
}
},
"required": [
"status"
"status",
"expected_version"
],
"title": "ModerationUpdate",
"type": "object"
@@ -1897,6 +1939,16 @@
"type": "string"
}
},
{
"in": "query",
"name": "expected_version",
"required": true,
"schema": {
"minimum": 0,
"title": "Expected Version",
"type": "integer"
}
},
{
"in": "header",
"name": "authorization",
@@ -2353,6 +2405,16 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ExternalObservationAction"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
+14 -11
View File
@@ -286,8 +286,11 @@ def test_user_report_requires_moderation_before_activity() -> None:
assert pending.status_code == 200
assert pending.headers["Cache-Control"] == "no-store"
assert any(item["id"] == report_id for item in pending.json())
approved = client.patch(f"/api/v1/admin/catch-reports/{report_id}", headers=headers, json={"status": "approved", "reason": "fixture verified"})
approved = client.patch(f"/api/v1/admin/catch-reports/{report_id}", headers=headers, json={"status": "approved", "reason": "fixture verified", "expected_version": 0})
assert approved.status_code == 200
stale = client.patch(f"/api/v1/admin/catch-reports/{report_id}", headers=headers, json={"status": "rejected", "reason": "stale tab", "expected_version": 0})
assert stale.status_code == 409
assert "reload" in stale.json()["detail"]
activity = client.get("/api/v1/activity?waterbody=test-lake&fish=pike&hours=24").json()
assert any(item["x"] == 77 and item["catches"] == 1 for item in activity["items"])
@@ -312,19 +315,19 @@ def test_external_observation_requires_mapping_and_complete_data_before_publicat
ExternalObservation.source_external_id == "review-complete"
))
headers = {"Authorization": "Bearer change-me-in-production"}
premature = client.post(f"/api/v1/admin/external-observations/{observation_id}/publish", headers=headers)
premature = client.post(f"/api/v1/admin/external-observations/{observation_id}/publish", headers=headers, json={"expected_version": 0})
assert premature.status_code == 409
mapped = client.patch(
f"/api/v1/admin/external-observations/{observation_id}/mapping", headers=headers,
json={"fish_slug": "pike", "waterbody_slug": "test-lake", "note": "verified fixture"},
json={"fish_slug": "pike", "waterbody_slug": "test-lake", "note": "verified fixture", "expected_version": 0},
)
assert mapped.status_code == 200
assert mapped.json()["status"] == "ready"
published = client.post(f"/api/v1/admin/external-observations/{observation_id}/publish", headers=headers)
published = client.post(f"/api/v1/admin/external-observations/{observation_id}/publish", headers=headers, json={"expected_version": 1})
assert published.status_code == 200
assert published.json()["status"] == "published"
repeated = client.post(f"/api/v1/admin/external-observations/{observation_id}/publish", headers=headers)
assert repeated.json()["catch_report_id"] == published.json()["catch_report_id"]
repeated = client.post(f"/api/v1/admin/external-observations/{observation_id}/publish", headers=headers, json={"expected_version": 1})
assert repeated.status_code == 409
with Session(engine) as db:
observation = db.get(ExternalObservation, observation_id)
report = db.get(CatchReport, observation.catch_report_id)
@@ -365,13 +368,13 @@ def test_incomplete_external_observation_is_publicly_labelled_but_not_counted()
assert all(item["x"] != 32 or item["y"] != 42 for item in client.get("/api/v1/activity").json()["items"])
mapped = client.patch(
f"/api/v1/admin/external-observations/{observation_id}/mapping", headers=headers,
json={"fish_slug": "pike", "waterbody_slug": "test-lake"},
json={"fish_slug": "pike", "waterbody_slug": "test-lake", "expected_version": 0},
)
assert mapped.json()["status"] == "mapped"
assert client.post(f"/api/v1/admin/external-observations/{observation_id}/publish", headers=headers).status_code == 409
assert client.post(f"/api/v1/admin/external-observations/{observation_id}/publish", headers=headers, json={"expected_version": 1}).status_code == 409
rejected = client.patch(
f"/api/v1/admin/external-observations/{observation_id}/reject", headers=headers,
json={"reason": "weight is absent"},
json={"reason": "weight is absent", "expected_version": 1},
)
assert rejected.json()["status"] == "rejected"
assert all(item["id"] != str(observation_id) for item in client.get("/api/v1/community-observations").json())
@@ -430,7 +433,7 @@ def test_admin_delete_anonymizes_report_removes_screenshot_and_keeps_audit(monke
deleted_keys: list[str] = []
monkeypatch.setattr("app.main.delete_screenshot", deleted_keys.append)
headers = {"Authorization": "Bearer change-me-in-production"}
response = client.delete(f"/api/v1/admin/catch-reports/{created['id']}", headers=headers)
response = client.delete(f"/api/v1/admin/catch-reports/{created['id']}?expected_version=0", headers=headers)
assert response.status_code == 204
assert deleted_keys == ["reports/private.jpg"]
with Session(engine) as db:
@@ -442,7 +445,7 @@ def test_admin_delete_anonymizes_report_removes_screenshot_and_keeps_audit(monke
event = db.query(ModerationEvent).filter_by(catch_report_id=report.id).order_by(ModerationEvent.created_at.desc()).first()
assert event is not None
assert event.reason == "user report deleted and anonymized"
assert client.delete(f"/api/v1/admin/catch-reports/{created['id']}", headers=headers).status_code == 404
assert client.delete(f"/api/v1/admin/catch-reports/{created['id']}?expected_version=0", headers=headers).status_code == 404
def test_catch_report_idempotency_key_prevents_duplicates(monkeypatch) -> None:
@@ -48,7 +48,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (filters) filters.hidden = true; if (list) list.innerHTML = ""; pages?.setAttribute("hidden", ""); if (message) fail(message); };
const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
const options = (items: Record<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.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.ok) throw new Error(`Запрос завершился ошибкой ${response.status}.`); if ((init.headers as Record<string, string> | undefined)?.Authorization) keepSession(); return response.json(); }
async function loadQueue() {
if (!root || !list) return; error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards();
if (!fishes.length || !waters.length) { const [fishRows, waterRows, sources] = await Promise.all([json(`${root.dataset.apiUrl}/api/v1/fishes`), json(`${root.dataset.apiUrl}/api/v1/waterbodies`), json(`${root.dataset.apiUrl}/api/v1/source-status`)]); fishes = fishRows; waters = waterRows; const sourceSelect = filters?.querySelector<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(""); }
@@ -63,7 +63,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
if (next) next.disabled = rows.length <= 50;
if (pageNumber) pageNumber.textContent = `Страница ${offset / 50 + 1}`;
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; const sourceUrl = safeHttpUrl(row.source_url); 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>${sourceUrl ? `<a href="${sourceUrl}" target="_blank" rel="noreferrer">Открыть первоисточник</a>` : '<span class="privacy">Ссылка источника имеет небезопасный формат</span>'}</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 data-action="secondary" type="button" data-suggest>Подсказать соответствия</button><button data-action="secondary" type="button" data-map>Сопоставить</button><button data-action="primary" type="button" data-publish ${complete && row.status === "ready" ? "" : "disabled"}>Опубликовать</button><button data-action="danger" 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; const sourceUrl = safeHttpUrl(row.source_url); return `<article class="moderation-card" data-observation-id="${esc(row.id)}" data-version="${esc(row.moderation_version)}"><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>${sourceUrl ? `<a href="${sourceUrl}" target="_blank" rel="noreferrer">Открыть первоисточник</a>` : '<span class="privacy">Ссылка источника имеет небезопасный формат</span>'}</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 data-action="secondary" type="button" data-suggest>Подсказать соответствия</button><button data-action="secondary" type="button" data-map>Сопоставить</button><button data-action="primary" type="button" data-publish ${complete && row.status === "ready" ? "" : "disabled"}>Опубликовать</button><button data-action="danger" type="button" data-reject>Отклонить</button></div></div></article>`; }).join("");
const payloadLabels: Record<string, string> = {bait:"Приманка",fishing_method:"Метод ловли",rig_type:"Оснастка",retrieve_method:"Проводка",retrieve_speed:"Скорость проводки",player_name:"Игрок",published_at:"Опубликовано источником",region:"Регион",category:"Категория"};
const missingLabels: Record<string, string> = {coordinates:"координаты",weight_g:"вес"};
pending.forEach((row: Record<string, unknown>, index: number) => {
@@ -103,7 +103,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
cardButtons.forEach(item => item.disabled = false);
return;
}
let message = "Наблюдение обновлено."; 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})}); message = "Соответствия сохранены."; } else if (button.hasAttribute("data-publish")) { await json(`${base}/publish`, {method:"POST",headers}); message = "Наблюдение опубликовано."; } 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})}); message = "Наблюдение отклонено."; } await loadQueue(); succeed(message); list.querySelector<HTMLButtonElement>("button")?.focus(); } catch (cause) { cardButtons.forEach(item => item.disabled = false); fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); } });
let message = "Наблюдение обновлено."; 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,expected_version:Number(card.dataset.version)})}); message = "Соответствия сохранены."; } else if (button.hasAttribute("data-publish")) { await json(`${base}/publish`, {method:"POST",headers,body:JSON.stringify({expected_version:Number(card.dataset.version)})}); message = "Наблюдение опубликовано."; } 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,expected_version:Number(card.dataset.version)})}); message = "Наблюдение отклонено."; } await loadQueue(); succeed(message); list.querySelector<HTMLButtonElement>("button")?.focus(); } 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 (target.matches("input,textarea,select") || event.ctrlKey || event.metaKey || event.altKey) return; const card = target.closest<HTMLElement>("[data-observation-id]"); const shortcuts: Record<string, string> = {s:"[data-suggest]",m:"[data-map]",p:"[data-publish]"}; const button = card?.querySelector<HTMLButtonElement>(shortcuts[event.key.toLowerCase()] ?? "[data-no-shortcut]"); if (button && !button.disabled) { event.preventDefault(); button.click(); } });
</script>
</Layout>
+2 -2
View File
@@ -39,7 +39,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
keepSession();
setLoading(false);
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)}"><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 : "Ошибка загрузки."); } });
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
@@ -49,7 +49,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.hasAttribute("data-delete") && !window.confirm("Удалить и обезличить эту заявку? Действие нельзя отменить.")) return;
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}`, {method:deleting ? "DELETE" : "PATCH",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:deleting ? undefined : JSON.stringify({status:button.dataset.decision,reason})}); if (response.status === 401) { 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 : "Ошибка сохранения."); }
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 : "Ошибка сохранения."); }
});
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>
+1 -1
View File
@@ -66,7 +66,7 @@
- [x] **M02 · Единый dashboard.** `/admin` показывает счётчики pending-уловов и staging-наблюдений, число активных источников, их безопасные публичные статусы, последние импорты и быстрые переходы в очереди. Dashboard использует тот же memory-only токен и 15-минутную сессию, не выводит секреты, внутренние URL и полные тексты исключений.
- [x] **M03 · Эффективность очередей.** Очередь внешних наблюдений получила серверные фильтры по источнику и полноте, безопасный поиск по рыбе/водоёму и сортировку по свежести или риску; проблемный порядок поднимает неполные и несопоставленные записи, а параметры работают до пагинации. Обе очереди блокируют всю карточку на время решения, сохраняют введённую причину при ошибке, явно подтверждают успех и переводят фокус к следующей записи. Безопасные горячие клавиши работают только внутри карточки с фокусом и отключены в полях ввода; отклонение и удаление намеренно оставлены только на кнопках.
- [x] **M04 · Полный provenance и история решений.** Admin API отдаёт время первого/последнего обнаружения и проверки, явный список missing fields и allowlist безопасных скалярных полей исходной записи; карточка показывает их перед публикацией. Единый read-only журнал объединяет решения по пользовательским и внешним записям без ников, URL и исходных payload и отображается на dashboard. Отдельный JSON-экспорт исключает также UUID сущностей, оператора и свободный текст причины; токен остаётся только в памяти вкладки.
- [ ] **M05 · Защита от параллельных решений.** Ввести version/updated-at precondition для optimistic locking и возвращать понятный `409`, если запись уже изменена другим модератором.
- [x] **M05 · Защита от параллельных решений.** Обе очереди отдают `moderation_version`; mapping/publish/reject/approve/delete требуют увиденную версию и повторно сверяют её под row lock. Успешное решение атомарно увеличивает version, а устаревшая вкладка получает понятный `409` и автоматически перезагружает очередь. Схема обновляется линейной миграцией `0015`.
- [ ] **M06 · Персональные роли — после пилота.** Если модераторов станет больше одного, заменить общий токен индивидуальными аккаунтами, короткими сессиями, отзывом доступа и ролями; писать идентификатор оператора в аудит. Для одного владельца альфы не добавлять отдельный auth-сервис заранее.
## Готовность открытой альфы — требуется сервер или внешний сервис