Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4d1c8b87b | ||
|
|
600abd81f5 |
@@ -8,7 +8,7 @@ RF4 Spotter — неофициальный сервис свежих точек
|
||||
|
||||
Функциональный MVP и локальный production-контур готовятся к открытой альфе: официальный импорт, пользовательские заявки, модерация, объяснимый индекс, staging внешних источников, адаптивный Astro UI, миграции, резервное копирование, retention, мониторинг и security/accessibility-проверки реализованы. Автоматические импорты внешних источников выключены. Публичный запуск блокируют покупка и настройка сервера, DNS/TLS, реальные секреты, внешний backup, канал уведомлений и публичные страницы правил/privacy.
|
||||
|
||||
RF4DB/RF4-STAT/RF4MAP/RF4 Posts принимаются только в изолированный staging и не влияют на индекс без ручного сопоставления и публикации. Для разрешённых community-источников действует интервал не менее 30 минут на источник.
|
||||
RF4DB/RF4-STAT/RF4MAP/RF4 Posts принимаются только в изолированный staging и не влияют на индекс без ручного сопоставления и публикации. Для разрешённых community-источников действует интервал не менее 30 минут на источник. Открытая альфа не использует продуктовый allowlist: интерфейс показывает весь корректно загруженный разрешённый каталог, сохраняя требования полноты и модерации.
|
||||
|
||||
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md).
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import CatchReport, ExternalObservation, Fish, SourceType, Spot, Waterbody
|
||||
|
||||
|
||||
def audit_catalog(db: Session) -> dict[str, int]:
|
||||
count = lambda model: db.scalar(select(func.count()).select_from(model)) or 0
|
||||
failures = {
|
||||
"invalid_weights": db.scalar(select(func.count()).select_from(CatchReport).where(or_(CatchReport.weight_g <= 0, CatchReport.weight_g > 3_000_000))) or 0,
|
||||
"invalid_coordinates": db.scalar(select(func.count()).select_from(Spot).where(or_(Spot.x < -10_000, Spot.x > 10_000, Spot.y < -10_000, Spot.y > 10_000))) or 0,
|
||||
"incomplete_official_records": db.scalar(select(func.count()).select_from(CatchReport).where(CatchReport.source_type == SourceType.official_record, or_(CatchReport.caught_at.is_(None), CatchReport.source_url.is_(None)))) or 0,
|
||||
"incomplete_published_staging": db.scalar(select(func.count()).select_from(ExternalObservation).where(ExternalObservation.status == "published", or_(ExternalObservation.fish_id.is_(None), ExternalObservation.waterbody_id.is_(None), ExternalObservation.x.is_(None), ExternalObservation.y.is_(None), ExternalObservation.weight_g.is_(None), ExternalObservation.catch_report_id.is_(None)))) or 0,
|
||||
}
|
||||
return {"fishes": count(Fish), "waterbodies": count(Waterbody), "reports": count(CatchReport), "staging": count(ExternalObservation), **failures, "failures": sum(failures.values())}
|
||||
+7
-1
@@ -11,6 +11,7 @@ from .importer import import_records
|
||||
from .community_importer import stage_observations
|
||||
from .retention import RetentionPolicy, apply_retention
|
||||
from .storage import delete_screenshot
|
||||
from .catalog_audit import audit_catalog
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -25,6 +26,7 @@ def main() -> int:
|
||||
community.add_argument("--limit", type=int, default=500)
|
||||
cleanup = sub.add_parser("cleanup-retention")
|
||||
cleanup.add_argument("--apply", action="store_true", help="apply changes; default is dry-run")
|
||||
sub.add_parser("audit-catalog")
|
||||
args = parser.parse_args()
|
||||
with SessionLocal() as session:
|
||||
if args.command == "import-records":
|
||||
@@ -43,7 +45,7 @@ def main() -> int:
|
||||
parser.error("input must be a JSON array")
|
||||
created, updated = stage_observations(session, payload[:args.limit])
|
||||
print(f"staged: created={created} updated={updated}")
|
||||
else:
|
||||
elif args.command == "cleanup-retention":
|
||||
policy = RetentionPolicy(
|
||||
submission_days=settings.retention_submission_days,
|
||||
unreviewed_days=settings.retention_unreviewed_days,
|
||||
@@ -54,6 +56,10 @@ def main() -> int:
|
||||
)
|
||||
counts = apply_retention(session, policy=policy, dry_run=not args.apply, delete_object=delete_screenshot)
|
||||
print(json.dumps({"mode": "apply" if args.apply else "dry-run", "policy": asdict(policy), "counts": counts}, ensure_ascii=False))
|
||||
else:
|
||||
result = audit_catalog(session)
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
return 1 if result["failures"] else 0
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ def test_list_pagination_and_filter_validation() -> None:
|
||||
headers = {"Authorization": "Bearer change-me-in-production"}
|
||||
assert client.get("/api/v1/admin/external-observations?status=unknown", headers=headers).status_code == 422
|
||||
assert client.get("/api/v1/admin/catch-reports?offset=-1", headers=headers).status_code == 422
|
||||
assert client.get("/api/v1/admin/catch-reports?limit=101", headers=headers).status_code == 422
|
||||
|
||||
|
||||
def test_liveness_does_not_probe_dependencies() -> None:
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.catalog_audit import audit_catalog
|
||||
from app.database import Base
|
||||
from app.models import CatchReport, Fish, ModerationStatus, SourceType, Spot, Waterbody
|
||||
|
||||
|
||||
def test_catalog_audit_checks_the_whole_catalog() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as db:
|
||||
fish = Fish(slug="pike", name_ru="Щука", trophy_weight_g=10_000)
|
||||
water = Waterbody(slug="lake", name_ru="Озеро", unlock_level=1)
|
||||
spot = Spot(waterbody=water, x=10, y=20)
|
||||
db.add(CatchReport(fish=fish, waterbody=water, spot=spot, weight_g=1000, reported_at=datetime.now(timezone.utc), source_type=SourceType.user, source_confidence=60, moderation_status=ModerationStatus.approved))
|
||||
db.commit()
|
||||
assert audit_catalog(db)["failures"] == 0
|
||||
spot.x = 10_001
|
||||
db.commit()
|
||||
result = audit_catalog(db)
|
||||
assert result["reports"] == 1
|
||||
assert result["invalid_coordinates"] == 1
|
||||
assert result["failures"] == 1
|
||||
@@ -6,6 +6,7 @@ import pytest
|
||||
from PIL import Image
|
||||
|
||||
from app.storage import ScreenshotError, prepare_image, validate_upload_metadata
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def test_prepare_image_removes_metadata() -> None:
|
||||
@@ -28,6 +29,11 @@ def test_prepare_image_rejects_non_image() -> None:
|
||||
prepare_image(b"not an image")
|
||||
|
||||
|
||||
def test_prepare_image_rejects_oversized_body_before_decoding() -> None:
|
||||
with pytest.raises(ScreenshotError, match="8 MB"):
|
||||
prepare_image(b"x" * (settings.screenshot_max_bytes + 1))
|
||||
|
||||
|
||||
def test_upload_metadata_requires_matching_supported_mime_and_extension() -> None:
|
||||
validate_upload_metadata("catch.jpeg", "image/jpeg")
|
||||
validate_upload_metadata("catch.webp", "image/webp")
|
||||
|
||||
+6
-6
@@ -109,9 +109,9 @@
|
||||
|
||||
- [x] Опубликовать страницы `/rules` и `/privacy`, добавить ссылки в footer и обязательное несохраняемое согласие перед отправкой улова.
|
||||
- [ ] Создать и проверить ящики `privacy@rf4spotter.ru` и `abuse@rf4spotter.ru`, определить срок реакции на обращения.
|
||||
- [ ] Проверить публичные abuse-сценарии: burst заявок, крупные файлы, повторные отправки и рост очереди модерации.
|
||||
- [ ] Зафиксировать короткий сценарий приёмки пилота и измеримые критерии: успешная отправка/модерация, понятность оценки, свежесть данных и допустимое время ответа.
|
||||
- [ ] Согласовать первые категории рекордов, водоёмы и виды рыб.
|
||||
- [x] Проверить локально публичные abuse-сценарии: шестая заявка блокируется, файл > 8 МБ отклоняется до декодирования, повтор screenshot запрещён, admin queue ограничена 100 строками; нагрузочный прогон остаётся серверным шагом.
|
||||
- [x] Зафиксировать сценарий приёмки открытой альфы, измеримые пороги и стоп-критерии (`docs/open-alpha-acceptance.md`).
|
||||
- [x] Зафиксировать охват открытой альфы: без продуктового allowlist, доступны все корректно загруженные категории, водоёмы и виды рыб из разрешённых источников; ограничения качества и модерации сохраняются.
|
||||
- [ ] Наполнить базу небольшим разрешённым набором реальных данных.
|
||||
- [ ] Провести тестирование с несколькими игроками по подготовленному сценарию.
|
||||
- [ ] Собрать обратную связь по полезности точек, понятности уверенности, форме улова и мобильному интерфейсу.
|
||||
@@ -122,9 +122,9 @@
|
||||
|
||||
Технический production-контур, health/readiness, backup/restore и безопасные логи готовы. Следующие пункты выполняются строго по одному:
|
||||
|
||||
1. создать и проверить контакты `privacy`/`abuse`;
|
||||
2. подготовить сценарий и измеримые критерии открытой альфы, включая публичную нагрузку и злоупотребления;
|
||||
3. мониторинг, DNS/TLS и проверка production-профиля на целевом сервере.
|
||||
1. наполнить полный разрешённый каталог; проверка целостности `audit-catalog` уже готова;
|
||||
2. создать и проверить контакты `privacy`/`abuse`;
|
||||
3. мониторинг, нагрузка, DNS/TLS и production-профиль на целевом сервере.
|
||||
|
||||
После каждого пункта необходимо:
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Приёмка открытой альфы RF4 Spotter
|
||||
|
||||
Версия: 7 сентября 2026 года. Проверка проводится сначала владельцем проекта, затем открыто реальными пользователями. Продуктового allowlist нет: доступны все категории рекордов, водоёмы и виды рыб, корректно полученные из разрешённых источников. Неполный или непроверенный staging не публикуется.
|
||||
|
||||
## Условия открытия
|
||||
|
||||
- production preflight, DNS, TLS, `/health` и `/ready` успешны;
|
||||
- настроены внешний backup, ежедневное обслуживание и уведомления мониторинга;
|
||||
- работают `privacy@rf4spotter.ru` и `abuse@rf4spotter.ru`, назначен ответственный;
|
||||
- опубликованы `/rules` и `/privacy`, форма требует согласия;
|
||||
- заглушки секретов отсутствуют, административные маршруты закрыты двумя уровнями авторизации;
|
||||
- импортирован полный доступный разрешённый каталог, демоданные отсутствуют;
|
||||
- по каждому источнику опубликованы время последнего успешного обновления и состояние ошибки.
|
||||
|
||||
## Сценарий игрока
|
||||
|
||||
1. На телефоне открыть главную, выбрать водоём и рыбу, получить результат не позднее второй высоты экрана.
|
||||
2. Открыть точку и своими словами объяснить активность, уверенность и свежесть.
|
||||
3. Отправить улов с обязательными полями и согласием; убедиться, что до модерации он не публичен.
|
||||
4. Модератор проверяет заявку, указывает причину отказа либо одобряет её.
|
||||
5. После одобрения точка появляется в выдаче; удаление заявки обезличивает данные и сохраняет аудит.
|
||||
6. Проверить рекорды и сброс фильтров на desktop и 390 px.
|
||||
|
||||
Проверка каталога выполняется по всем загруженным сущностям автоматически: уникальность slug, существование связей, диапазоны координат/веса, отсутствие неподтверждённого staging в публичном API. Ручной smoke использует минимум по одному представителю каждого водоёма и каждой категории рекордов; редкие виды проверяются выборочно, а не исключаются из публикации.
|
||||
|
||||
Перед каждым релизом выполнить `docker compose exec -T api python -m app.cli audit-catalog`. Ненулевой `failures` или exit code 1 блокирует открытие/обновление.
|
||||
|
||||
## Измеримые критерии
|
||||
|
||||
| Область | Проходной порог |
|
||||
|---|---|
|
||||
| Доступность | axe: 0 critical/serious; Lighthouse accessibility ≥ 95 |
|
||||
| Интерфейс | нет горизонтального overflow на 390 px; ≥ 80% участников завершают поиск без подсказки |
|
||||
| Каталог | 100% публичных записей имеют валидные связи; фильтры принимают весь загруженный справочник без allowlist |
|
||||
| Понятность | ≥ 80% верно объясняют разницу активности и уверенности |
|
||||
| API | p95 публичных списков ≤ 250 мс; admin queue ≤ 500 мс на пилотном объёме |
|
||||
| Надёжность | readiness успешен; backup моложе 26 часов и проходит checksum |
|
||||
| Модерация | 100% пользовательских заявок сначала `pending`; медианное решение ≤ 12 часов |
|
||||
| Свежесть | успешный разрешённый импорт не старше настроенного интервала; сбой явно виден в readiness/monitoring |
|
||||
| Abuse | шестая заявка одного клиента за 10 минут получает `429`; файл > 8 МБ отклоняется; повторный screenshot отклоняется |
|
||||
| Обращения | abuse подтверждается ≤ 24 часов, privacy/delete ≤ 72 часов |
|
||||
|
||||
## Стоп-критерии
|
||||
|
||||
Альфа закрывается для новых отправок при утечке данных или секретов, потере возможности восстановления, обходе модерации, недоступности обоих контактов более 24 часов либо устойчивой ошибке 5xx выше 2% за 15 минут. Публичное чтение можно оставить только если оно не усугубляет инцидент.
|
||||
|
||||
## Результат прогона
|
||||
|
||||
Для каждого запуска фиксируются дата, версия/коммит, участники, объём данных, p95, ошибки, возраст backup, результаты axe/Lighthouse и решение `GO`/`NO-GO`. Блокирующие дефекты получают владельца и срок повторной проверки.
|
||||
Reference in New Issue
Block a user