From 0abe1a2bb448b4ddaab69ed2b57d0d644248bd19 Mon Sep 17 00:00:00 2001 From: IK Date: Sun, 13 Sep 2026 09:26:18 +0700 Subject: [PATCH] feat: lock concurrent moderation decisions --- README.md | 2 + .../versions/0015_moderation_version.py | 22 ++++++ apps/api/app/main.py | 34 ++++++--- apps/api/app/models.py | 2 + apps/api/app/schemas.py | 9 +++ apps/api/openapi.json | 72 +++++++++++++++++-- apps/api/tests/test_api.py | 25 ++++--- .../src/pages/admin/external-sources.astro | 6 +- apps/web/src/pages/admin/moderation.astro | 4 +- docs/ROADMAP.md | 2 +- 10 files changed, 147 insertions(+), 31 deletions(-) create mode 100644 apps/api/alembic/versions/0015_moderation_version.py diff --git a/README.md b/README.md index a12d92c..7525a5b 100644 --- a/README.md +++ b/README.md @@ -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); diff --git a/apps/api/alembic/versions/0015_moderation_version.py b/apps/api/alembic/versions/0015_moderation_version.py new file mode 100644 index 0000000..ff7621d --- /dev/null +++ b/apps/api/alembic/versions/0015_moderation_version.py @@ -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") diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 7706da2..5f60caf 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -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 diff --git a/apps/api/app/models.py b/apps/api/app/models.py index 6cba8c8..e3482cd 100644 --- a/apps/api/app/models.py +++ b/apps/api/app/models.py @@ -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() diff --git a/apps/api/app/schemas.py b/apps/api/app/schemas.py index 58f473f..5bfe881 100644 --- a/apps/api/app/schemas.py +++ b/apps/api/app/schemas.py @@ -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): diff --git a/apps/api/openapi.json b/apps/api/openapi.json index fc465e8..78561d6 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -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": { diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index 95b2af7..58175a9 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -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: diff --git a/apps/web/src/pages/admin/external-sources.astro b/apps/web/src/pages/admin/external-sources.astro index 5c9ca86..7824b2d 100644 --- a/apps/web/src/pages/admin/external-sources.astro +++ b/apps/web/src/pages/admin/external-sources.astro @@ -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[], selected?: unknown) => items.map(item => ``).join(""); - async function json(url: string, init: RequestInit = {}) { const response = await fetch(url, init); if (response.status === 401) { endSession(); throw new Error("Неверный или истёкший административный токен."); } if (!response.ok) throw new Error(`Запрос завершился ошибкой ${response.status}.`); if ((init.headers as Record | undefined)?.Authorization) keepSession(); return response.json(); } + async function json(url: string, init: RequestInit = {}) { const response = await fetch(url, init); if (response.status === 401) { endSession(); throw new Error("Неверный или истёкший административный токен."); } if (response.status === 409) { await loadQueue(); throw new Error("Запись уже изменена в другой вкладке. Очередь обновлена."); } if (!response.ok) throw new Error(`Запрос завершился ошибкой ${response.status}.`); if ((init.headers as Record | undefined)?.Authorization) keepSession(); return response.json(); } async function loadQueue() { if (!root || !list) return; error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards(); if (!fishes.length || !waters.length) { const [fishRows, waterRows, sources] = await Promise.all([json(`${root.dataset.apiUrl}/api/v1/fishes`), json(`${root.dataset.apiUrl}/api/v1/waterbodies`), json(`${root.dataset.apiUrl}/api/v1/source-status`)]); fishes = fishRows; waters = waterRows; const sourceSelect = filters?.querySelector('[name="source"]'); if (sourceSelect) sourceSelect.innerHTML = '' + (sources as Record[]).map(source => ``).join(""); } @@ -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 = '

Очередь пуста

Все внешние записи обработаны.

'; return; } - list.innerHTML = pending.map((row: Record) => { const complete = row.x != null && row.y != null && row.weight_g != null; const sourceUrl = safeHttpUrl(row.source_url); return `
${esc(row.source_system)} · ${esc(row.status)}

${esc(row.fish_name)}

${esc(row.waterbody_name)}

Координаты
${row.x == null || row.y == null ? "нет" : `${esc(row.x)}:${esc(row.y)}`}
Вес
${row.weight_g == null ? "нет" : `${esc(row.weight_g)} г`}
ID источника
${esc(row.source_external_id)}
Состояние
${complete ? "полная запись" : "неполная"}
${sourceUrl ? `Открыть первоисточник` : 'Ссылка источника имеет небезопасный формат'}

`; }).join(""); + list.innerHTML = pending.map((row: Record) => { const complete = row.x != null && row.y != null && row.weight_g != null; const sourceUrl = safeHttpUrl(row.source_url); return `
${esc(row.source_system)} · ${esc(row.status)}

${esc(row.fish_name)}

${esc(row.waterbody_name)}

Координаты
${row.x == null || row.y == null ? "нет" : `${esc(row.x)}:${esc(row.y)}`}
Вес
${row.weight_g == null ? "нет" : `${esc(row.weight_g)} г`}
ID источника
${esc(row.source_external_id)}
Состояние
${complete ? "полная запись" : "неполная"}
${sourceUrl ? `Открыть первоисточник` : 'Ссылка источника имеет небезопасный формат'}

`; }).join(""); const payloadLabels: Record = {bait:"Приманка",fishing_method:"Метод ловли",rig_type:"Оснастка",retrieve_method:"Проводка",retrieve_speed:"Скорость проводки",player_name:"Игрок",published_at:"Опубликовано источником",region:"Регион",category:"Категория"}; const missingLabels: Record = {coordinates:"координаты",weight_g:"вес"}; pending.forEach((row: Record, 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('[name="fish"]')?.value; const waterbody_slug = card.querySelector('[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('[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('[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("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('[name="fish"]')?.value; const waterbody_slug = card.querySelector('[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('[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('[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("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("[data-observation-id]"); const shortcuts: Record = {s:"[data-suggest]",m:"[data-map]",p:"[data-publish]"}; const button = card?.querySelector(shortcuts[event.key.toLowerCase()] ?? "[data-no-shortcut]"); if (button && !button.disabled) { event.preventDefault(); button.click(); } }); diff --git a/apps/web/src/pages/admin/moderation.astro b/apps/web/src/pages/admin/moderation.astro index fdbcda8..c3855de 100644 --- a/apps/web/src/pages/admin/moderation.astro +++ b/apps/web/src/pages/admin/moderation.astro @@ -39,7 +39,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; keepSession(); setLoading(false); if (!reports.length) { list.innerHTML = '

Очередь пуста

Новых уловов для проверки нет.

'; return; } - list.innerHTML = reports.map(report => { const screenshotUrl = safeHttpUrl(report.screenshot_url); return `
На проверке

${esc(report.fish)}

${esc(report.waterbody)} · ${esc(report.coordinates)}

Вес
${esc(report.weight_g)} г
Приманка
${esc(report.bait)}
Игрок
${esc(report.player_name)}
Отправлено
${esc(new Date(String(report.reported_at)).toLocaleString("ru-RU"))}
${report.comment ? `
${esc(report.comment)}
` : ""}
${screenshotUrl ? `Скриншот улова ${esc(report.fish)}` : '
Скриншот не приложен
'}
`; }).join(""); + list.innerHTML = reports.map(report => { const screenshotUrl = safeHttpUrl(report.screenshot_url); return `
На проверке

${esc(report.fish)}

${esc(report.waterbody)} · ${esc(report.coordinates)}

Вес
${esc(report.weight_g)} г
Приманка
${esc(report.bait)}
Игрок
${esc(report.player_name)}
Отправлено
${esc(new Date(String(report.reported_at)).toLocaleString("ru-RU"))}
${report.comment ? `
${esc(report.comment)}
` : ""}
${screenshotUrl ? `Скриншот улова ${esc(report.fish)}` : '
Скриншот не приложен
'}
`; }).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("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("button[data-decision]"); if (nextAction) nextAction.focus(); else list.innerHTML = '

Очередь пуста

Все записи обработаны.

'; } 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("button[data-decision]"); if (nextAction) nextAction.focus(); else list.innerHTML = '

Очередь пуста

Все записи обработаны.

'; } 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("[data-report-id]"); const approve = card?.querySelector('[data-decision="approved"]'); if (approve && !approve.disabled) { event.preventDefault(); approve.click(); } }); diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5c7f576..1ee5b7c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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-сервис заранее. ## Готовность открытой альфы — требуется сервер или внешний сервис