feat: add production data retention
This commit is contained in:
@@ -24,4 +24,12 @@ OFFICIAL_RECORDS_REGION=RU
|
|||||||
OFFICIAL_RECORDS_CATEGORY=records
|
OFFICIAL_RECORDS_CATEGORY=records
|
||||||
OFFICIAL_IMPORT_REQUIRED=false
|
OFFICIAL_IMPORT_REQUIRED=false
|
||||||
IMPORT_INTERVAL_SECONDS=3600
|
IMPORT_INTERVAL_SECONDS=3600
|
||||||
|
|
||||||
|
# Data retention for the closed alpha; see docs/data-retention.md.
|
||||||
|
RETENTION_SUBMISSION_DAYS=1
|
||||||
|
RETENTION_UNREVIEWED_DAYS=30
|
||||||
|
RETENTION_APPROVED_PERSONAL_DAYS=180
|
||||||
|
RETENTION_STAGING_DAYS=90
|
||||||
|
RETENTION_AUDIT_DAYS=365
|
||||||
|
RETENTION_PUBLISHED_PAYLOAD_DAYS=365
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ RF4 Spotter — неофициальный сервис свежих точек
|
|||||||
|
|
||||||
Production-контур для домена `rf4spotter.ru`, TLS, секреты, backup/restore и команды первого запуска описаны в [`deploy/README.md`](deploy/README.md). Он использует отдельный `compose.production.yaml`; локальный `compose.yaml` остаётся средой разработки. Production seed добавляет только справочники — демонстрационные уловы отключены. Изолированные проверки `deploy/test-production-bootstrap.sh` и `deploy/test-backup-restore.sh` подтверждают старт с пустых volumes и восстановление данных.
|
Production-контур для домена `rf4spotter.ru`, TLS, секреты, backup/restore и команды первого запуска описаны в [`deploy/README.md`](deploy/README.md). Он использует отдельный `compose.production.yaml`; локальный `compose.yaml` остаётся средой разработки. Production seed добавляет только справочники — демонстрационные уловы отключены. Изолированные проверки `deploy/test-production-bootstrap.sh` и `deploy/test-backup-restore.sh` подтверждают старт с пустых volumes и восстановление данных.
|
||||||
|
|
||||||
|
Политика минимизации данных и ежедневная dry-run-first очистка описаны в [`docs/data-retention.md`](docs/data-retention.md).
|
||||||
|
|
||||||
Gitea Actions workflow `.gitea/workflows/ci.yml` на каждый push и pull request проверяет Python, миграции на чистой PostgreSQL, Astro build и полный Compose/Playwright-сценарий. При падении E2E сохраняются логи контейнеров и Playwright-артефакты.
|
Gitea Actions workflow `.gitea/workflows/ci.yml` на каждый push и pull request проверяет Python, миграции на чистой PostgreSQL, Astro build и полный Compose/Playwright-сценарий. При падении E2E сохраняются логи контейнеров и Playwright-артефакты.
|
||||||
|
|
||||||
Актуальная инвентаризация источников и правила подключения адаптеров находятся в [`docs/data-source-audit.md`](docs/data-source-audit.md). Разрешённый технический пилот RF4DB/RF4-STAT описан в [`docs/community-source-pilot.md`](docs/community-source-pilot.md), а статус разрешений и лимитов — в [`docs/data-permissions.md`](docs/data-permissions.md). Данные сохраняются только в промежуточный staging и не влияют на индекс без явной проверки и публикации администратором.
|
Актуальная инвентаризация источников и правила подключения адаптеров находятся в [`docs/data-source-audit.md`](docs/data-source-audit.md). Разрешённый технический пилот RF4DB/RF4-STAT описан в [`docs/community-source-pilot.md`](docs/community-source-pilot.md), а статус разрешений и лимитов — в [`docs/data-permissions.md`](docs/data-permissions.md). Данные сохраняются только в промежуточный staging и не влияют на индекс без явной проверки и публикации администратором.
|
||||||
@@ -79,7 +81,7 @@ docker compose up --build
|
|||||||
## Что реализовано
|
## Что реализовано
|
||||||
|
|
||||||
- FastAPI и SQLAlchemy 2;
|
- FastAPI и SQLAlchemy 2;
|
||||||
- PostgreSQL 17 и миграции Alembic до `0009`;
|
- PostgreSQL 17 и миграции Alembic до `0010`;
|
||||||
- идемпотентный seed с двумя точками и свежими демо-уловами;
|
- идемпотентный seed с двумя точками и свежими демо-уловами;
|
||||||
- `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки;
|
- `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки;
|
||||||
- `GET /api/v1/spots/{id}` и `/catches`;
|
- `GET /api/v1/spots/{id}` и `/catches`;
|
||||||
@@ -91,7 +93,7 @@ docker compose up --build
|
|||||||
- идемпотентный импорт официальных записей с журналом запусков;
|
- идемпотентный импорт официальных записей с журналом запусков;
|
||||||
- публичная страница `/records` с источником и временем последнего импорта;
|
- публичная страница `/records` с источником и временем последнего импорта;
|
||||||
- форма `/report`, защищённые admin API и журнал модерации;
|
- форма `/report`, защищённые admin API и журнал модерации;
|
||||||
- отдельные состояния ошибки создания заявки и загрузки скриншота; неудачный скриншот можно добавить повторно по ID уже сохранённой заявки;
|
- отдельные состояния ошибки создания заявки и загрузки скриншота; неудачный скриншот можно добавить повторно по ID и одноразовому секрету уже сохранённой заявки;
|
||||||
- honeypot и постоянный rate limit в PostgreSQL с HMAC-отпечатками вместо исходных IP;
|
- honeypot и постоянный rate limit в PostgreSQL с HMAC-отпечатками вместо исходных IP;
|
||||||
- скриншоты уловов в MinIO/S3 с проверкой MIME, расширения, размера и фактического содержимого, повторным кодированием и очисткой метаданных;
|
- скриншоты уловов в MinIO/S3 с проверкой MIME, расширения, размера и фактического содержимого, повторным кодированием и очисткой метаданных;
|
||||||
- административная очередь `/admin/moderation` с одобрением, отклонением и обезличенным удалением записи с аудитом.
|
- административная очередь `/admin/moderation` с одобрением, отклонением и обезличенным удалением записи с аудитом.
|
||||||
@@ -159,7 +161,7 @@ curl -H "Authorization: Bearer change-me-in-production" \
|
|||||||
|
|
||||||
Перед внешним развёртыванием обязательно замените демонстрационные `ADMIN_TOKEN`, `RATE_LIMIT_SECRET`, `S3_ACCESS_KEY` и `S3_SECRET_KEY`. Форма принимает JPEG, PNG и WebP до 8 МБ; API сверяет MIME и расширение с фактическим форматом, повторно кодирует изображение и удаляет EXIF перед сохранением в MinIO. Модератор получает временную подписанную ссылку через admin API.
|
Перед внешним развёртыванием обязательно замените демонстрационные `ADMIN_TOKEN`, `RATE_LIMIT_SECRET`, `S3_ACCESS_KEY` и `S3_SECRET_KEY`. Форма принимает JPEG, PNG и WebP до 8 МБ; API сверяет MIME и расширение с фактическим форматом, повторно кодирует изображение и удаляет EXIF перед сохранением в MinIO. Модератор получает временную подписанную ссылку через admin API.
|
||||||
|
|
||||||
Если создание записи прошло успешно, а загрузка скриншота завершилась ошибкой, форма сохраняет ID заявки и предлагает повторить только загрузку изображения. Повторно отправлять сам улов не требуется.
|
Если создание записи прошло успешно, а загрузка скриншота завершилась ошибкой, форма сохраняет на один час ID заявки и одноразовый секрет в защищённой `HttpOnly` cookie и предлагает повторить только загрузку изображения. Повторно отправлять сам улов не требуется; один UUID заявки не даёт права изменить чужую запись.
|
||||||
|
|
||||||
Очередь модерации доступна по адресу <http://localhost:4321/admin/moderation>. Администратор вводит `ADMIN_TOKEN`; интерфейс держит его только в памяти открытой страницы и не сохраняет в URL или браузерном хранилище.
|
Очередь модерации доступна по адресу <http://localhost:4321/admin/moderation>. Администратор вводит `ADMIN_TOKEN`; интерфейс держит его только в памяти открытой страницы и не сохраняет в URL или браузерном хранилище.
|
||||||
|
|
||||||
|
|||||||
+18
-1
@@ -3,10 +3,14 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
from dataclasses import asdict
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
from .database import SessionLocal
|
from .database import SessionLocal
|
||||||
from .importer import import_records
|
from .importer import import_records
|
||||||
from .community_importer import stage_observations
|
from .community_importer import stage_observations
|
||||||
|
from .retention import RetentionPolicy, apply_retention
|
||||||
|
from .storage import delete_screenshot
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
@@ -19,12 +23,14 @@ def main() -> int:
|
|||||||
community = sub.add_parser("stage-community-json")
|
community = sub.add_parser("stage-community-json")
|
||||||
community.add_argument("--input", default="-", help="JSON array path or - for stdin")
|
community.add_argument("--input", default="-", help="JSON array path or - for stdin")
|
||||||
community.add_argument("--limit", type=int, default=500)
|
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")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
with SessionLocal() as session:
|
with SessionLocal() as session:
|
||||||
if args.command == "import-records":
|
if args.command == "import-records":
|
||||||
run = import_records(session, url=args.url, region=args.region, category=args.category)
|
run = import_records(session, url=args.url, region=args.region, category=args.category)
|
||||||
print(f"import {run.status.value}: seen={run.rows_seen} created={run.rows_created} updated={run.rows_updated}")
|
print(f"import {run.status.value}: seen={run.rows_seen} created={run.rows_created} updated={run.rows_updated}")
|
||||||
else:
|
elif args.command == "stage-community-json":
|
||||||
if not 1 <= args.limit <= 5000:
|
if not 1 <= args.limit <= 5000:
|
||||||
parser.error("--limit must be between 1 and 5000")
|
parser.error("--limit must be between 1 and 5000")
|
||||||
stream = sys.stdin if args.input == "-" else open(args.input, encoding="utf-8")
|
stream = sys.stdin if args.input == "-" else open(args.input, encoding="utf-8")
|
||||||
@@ -37,6 +43,17 @@ def main() -> int:
|
|||||||
parser.error("input must be a JSON array")
|
parser.error("input must be a JSON array")
|
||||||
created, updated = stage_observations(session, payload[:args.limit])
|
created, updated = stage_observations(session, payload[:args.limit])
|
||||||
print(f"staged: created={created} updated={updated}")
|
print(f"staged: created={created} updated={updated}")
|
||||||
|
else:
|
||||||
|
policy = RetentionPolicy(
|
||||||
|
submission_days=settings.retention_submission_days,
|
||||||
|
unreviewed_days=settings.retention_unreviewed_days,
|
||||||
|
approved_personal_days=settings.retention_approved_personal_days,
|
||||||
|
staging_days=settings.retention_staging_days,
|
||||||
|
audit_days=settings.retention_audit_days,
|
||||||
|
published_payload_days=settings.retention_published_payload_days,
|
||||||
|
)
|
||||||
|
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))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ class Settings(BaseSettings):
|
|||||||
official_records_category: str = "records"
|
official_records_category: str = "records"
|
||||||
official_import_required: bool = False
|
official_import_required: bool = False
|
||||||
seed_demo_data: bool = True
|
seed_demo_data: bool = True
|
||||||
|
retention_submission_days: int = Field(default=1, ge=1)
|
||||||
|
retention_unreviewed_days: int = Field(default=30, ge=7)
|
||||||
|
retention_approved_personal_days: int = Field(default=180, ge=30)
|
||||||
|
retention_staging_days: int = Field(default=90, ge=30)
|
||||||
|
retention_audit_days: int = Field(default=365, ge=90)
|
||||||
|
retention_published_payload_days: int = Field(default=365, ge=90)
|
||||||
import_interval_seconds: int = Field(default=3600, ge=3600)
|
import_interval_seconds: int = Field(default=3600, ge=3600)
|
||||||
rate_limit_secret: str = "change-rate-limit-secret"
|
rate_limit_secret: str = "change-rate-limit-secret"
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from sqlalchemy import delete, or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .models import CatchReport, ExternalObservation, ModerationEvent, ModerationStatus, SourceType, SubmissionAttempt
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RetentionPolicy:
|
||||||
|
submission_days: int = 1
|
||||||
|
unreviewed_days: int = 30
|
||||||
|
approved_personal_days: int = 180
|
||||||
|
staging_days: int = 90
|
||||||
|
audit_days: int = 365
|
||||||
|
published_payload_days: int = 365
|
||||||
|
|
||||||
|
|
||||||
|
def apply_retention(
|
||||||
|
session: Session, *, policy: RetentionPolicy = RetentionPolicy(),
|
||||||
|
now: datetime | None = None, dry_run: bool = True,
|
||||||
|
delete_object: Callable[[str], None] | None = None,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
current = now or datetime.now(timezone.utc)
|
||||||
|
counts = {
|
||||||
|
"submission_attempts": 0,
|
||||||
|
"user_reports_anonymized": 0,
|
||||||
|
"pending_reports_expired": 0,
|
||||||
|
"screenshots_deleted": 0,
|
||||||
|
"staging_observations_deleted": 0,
|
||||||
|
"published_payloads_cleared": 0,
|
||||||
|
"moderation_events_deleted": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts = list(session.scalars(select(SubmissionAttempt.id).where(
|
||||||
|
SubmissionAttempt.created_at < current - timedelta(days=policy.submission_days),
|
||||||
|
)))
|
||||||
|
counts["submission_attempts"] = len(attempts)
|
||||||
|
|
||||||
|
candidate_cutoff = current - timedelta(days=min(policy.unreviewed_days, policy.approved_personal_days))
|
||||||
|
candidates = list(session.scalars(select(CatchReport).where(
|
||||||
|
CatchReport.source_type == SourceType.user,
|
||||||
|
CatchReport.reported_at < candidate_cutoff,
|
||||||
|
or_(
|
||||||
|
CatchReport.player_name.is_not(None), CatchReport.source_url.is_not(None),
|
||||||
|
CatchReport.raw_payload.is_not(None), CatchReport.screenshot_key.is_not(None),
|
||||||
|
CatchReport.screenshot_upload_token_hash.is_not(None),
|
||||||
|
),
|
||||||
|
)))
|
||||||
|
reports = [item for item in candidates if item.reported_at.replace(tzinfo=item.reported_at.tzinfo or timezone.utc) < current - timedelta(
|
||||||
|
days=policy.approved_personal_days if item.moderation_status == ModerationStatus.approved else policy.unreviewed_days,
|
||||||
|
)]
|
||||||
|
counts["user_reports_anonymized"] = len(reports)
|
||||||
|
counts["screenshots_deleted"] = sum(bool(item.screenshot_key) for item in reports)
|
||||||
|
counts["pending_reports_expired"] = sum(item.moderation_status == ModerationStatus.pending for item in reports)
|
||||||
|
|
||||||
|
stale = list(session.scalars(select(ExternalObservation).where(
|
||||||
|
ExternalObservation.catch_report_id.is_(None),
|
||||||
|
ExternalObservation.status != "published",
|
||||||
|
ExternalObservation.last_seen_at < current - timedelta(days=policy.staging_days),
|
||||||
|
)))
|
||||||
|
counts["staging_observations_deleted"] = len(stale)
|
||||||
|
published = list(session.scalars(select(ExternalObservation).where(
|
||||||
|
ExternalObservation.status == "published",
|
||||||
|
ExternalObservation.last_seen_at < current - timedelta(days=policy.published_payload_days),
|
||||||
|
)))
|
||||||
|
published = [item for item in published if item.payload]
|
||||||
|
counts["published_payloads_cleared"] = len(published)
|
||||||
|
events = list(session.scalars(select(ModerationEvent.id).where(
|
||||||
|
ModerationEvent.created_at < current - timedelta(days=policy.audit_days),
|
||||||
|
)))
|
||||||
|
counts["moderation_events_deleted"] = len(events)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
return counts
|
||||||
|
|
||||||
|
if counts["screenshots_deleted"] and delete_object is None:
|
||||||
|
raise ValueError("delete_object is required when retained screenshots must be deleted")
|
||||||
|
|
||||||
|
for report in reports:
|
||||||
|
if report.screenshot_key and delete_object:
|
||||||
|
delete_object(report.screenshot_key)
|
||||||
|
if report.moderation_status == ModerationStatus.pending:
|
||||||
|
session.add(ModerationEvent(
|
||||||
|
catch_report=report, created_at=current,
|
||||||
|
previous_status=ModerationStatus.pending, new_status=ModerationStatus.rejected,
|
||||||
|
moderator="retention-policy", reason="pending report retention period expired",
|
||||||
|
))
|
||||||
|
report.moderation_status = ModerationStatus.rejected
|
||||||
|
report.player_name = None
|
||||||
|
report.source_url = None
|
||||||
|
report.raw_payload = None
|
||||||
|
report.screenshot_key = None
|
||||||
|
report.screenshot_upload_token_hash = None
|
||||||
|
for observation in stale:
|
||||||
|
session.delete(observation)
|
||||||
|
for observation in published:
|
||||||
|
observation.payload = {}
|
||||||
|
if attempts:
|
||||||
|
session.execute(delete(SubmissionAttempt).where(SubmissionAttempt.id.in_(attempts)))
|
||||||
|
if events:
|
||||||
|
session.execute(delete(ModerationEvent).where(ModerationEvent.id.in_(events)))
|
||||||
|
session.commit()
|
||||||
|
return counts
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
from app.models import CatchReport, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, SourceType, SubmissionAttempt, Waterbody
|
||||||
|
from app.retention import apply_retention
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 9, 6, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_retention_dry_run_then_apply() -> 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)
|
||||||
|
waterbody = Waterbody(slug="lake", name_ru="Озеро", unlock_level=1)
|
||||||
|
source = DataSource(key="rf4db", name="RF4DB", base_url="https://rf4db.com", default_confidence=60, enabled=False)
|
||||||
|
pending = CatchReport(
|
||||||
|
fish=fish, waterbody=waterbody, weight_g=1000, reported_at=NOW - timedelta(days=31),
|
||||||
|
player_name="Private", source_type=SourceType.user, source_confidence=60,
|
||||||
|
moderation_status=ModerationStatus.pending, raw_payload={"comment": "private"},
|
||||||
|
screenshot_key="reports/old.jpg", screenshot_upload_token_hash="a" * 64,
|
||||||
|
)
|
||||||
|
approved = CatchReport(
|
||||||
|
fish=fish, waterbody=waterbody, weight_g=2000, reported_at=NOW - timedelta(days=181),
|
||||||
|
player_name="Old winner", source_type=SourceType.user, source_confidence=60,
|
||||||
|
moderation_status=ModerationStatus.approved, raw_payload={"comment": "old"},
|
||||||
|
)
|
||||||
|
fresh = CatchReport(
|
||||||
|
fish=fish, waterbody=waterbody, weight_g=3000, reported_at=NOW - timedelta(days=10),
|
||||||
|
player_name="Fresh", source_type=SourceType.user, source_confidence=60,
|
||||||
|
moderation_status=ModerationStatus.pending, raw_payload={"comment": "fresh"},
|
||||||
|
)
|
||||||
|
db.add_all([source, pending, approved, fresh])
|
||||||
|
db.flush()
|
||||||
|
db.add(SubmissionAttempt(client_hash="x" * 64, created_at=NOW - timedelta(days=2)))
|
||||||
|
db.add(ModerationEvent(catch_report=approved, created_at=NOW - timedelta(days=366), previous_status=ModerationStatus.pending, new_status=ModerationStatus.approved, moderator="admin"))
|
||||||
|
db.add(ExternalObservation(source=source, source_external_id="stale", source_url="https://rf4db.com/1", fish_name="Щука", waterbody_name="Озеро", first_seen_at=NOW - timedelta(days=100), last_seen_at=NOW - timedelta(days=100), status="staged", payload={"raw": True}))
|
||||||
|
db.add(ExternalObservation(source=source, source_external_id="published", source_url="https://rf4db.com/2", fish_name="Щука", waterbody_name="Озеро", first_seen_at=NOW - timedelta(days=400), last_seen_at=NOW - timedelta(days=400), status="published", payload={"raw": True}, catch_report=approved))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
preview = apply_retention(db, now=NOW)
|
||||||
|
assert preview == {"submission_attempts": 1, "user_reports_anonymized": 2, "pending_reports_expired": 1, "screenshots_deleted": 1, "staging_observations_deleted": 1, "published_payloads_cleared": 1, "moderation_events_deleted": 1}
|
||||||
|
assert db.get(CatchReport, pending.id).player_name == "Private"
|
||||||
|
|
||||||
|
deleted: list[str] = []
|
||||||
|
assert apply_retention(db, now=NOW, dry_run=False, delete_object=deleted.append) == preview
|
||||||
|
assert deleted == ["reports/old.jpg"]
|
||||||
|
assert db.get(CatchReport, pending.id).moderation_status == ModerationStatus.rejected
|
||||||
|
assert db.get(CatchReport, pending.id).player_name is None
|
||||||
|
assert db.get(CatchReport, approved.id).raw_payload is None
|
||||||
|
assert db.get(CatchReport, fresh.id).player_name == "Fresh"
|
||||||
|
assert db.scalar(select(func.count()).select_from(SubmissionAttempt)) == 0
|
||||||
|
assert db.scalar(select(func.count()).select_from(ExternalObservation)) == 1
|
||||||
|
assert db.scalar(select(ExternalObservation)).payload == {}
|
||||||
|
assert db.scalar(select(func.count()).select_from(ModerationEvent)) == 1
|
||||||
@@ -89,6 +89,12 @@ services:
|
|||||||
SEED_DEMO_DATA: "false"
|
SEED_DEMO_DATA: "false"
|
||||||
IMPORT_INTERVAL_SECONDS: ${IMPORT_INTERVAL_SECONDS:-3600}
|
IMPORT_INTERVAL_SECONDS: ${IMPORT_INTERVAL_SECONDS:-3600}
|
||||||
RATE_LIMIT_SECRET: ${RATE_LIMIT_SECRET:?Set RATE_LIMIT_SECRET}
|
RATE_LIMIT_SECRET: ${RATE_LIMIT_SECRET:?Set RATE_LIMIT_SECRET}
|
||||||
|
RETENTION_SUBMISSION_DAYS: ${RETENTION_SUBMISSION_DAYS:-1}
|
||||||
|
RETENTION_UNREVIEWED_DAYS: ${RETENTION_UNREVIEWED_DAYS:-30}
|
||||||
|
RETENTION_APPROVED_PERSONAL_DAYS: ${RETENTION_APPROVED_PERSONAL_DAYS:-180}
|
||||||
|
RETENTION_STAGING_DAYS: ${RETENTION_STAGING_DAYS:-90}
|
||||||
|
RETENTION_AUDIT_DAYS: ${RETENTION_AUDIT_DAYS:-365}
|
||||||
|
RETENTION_PUBLISHED_PAYLOAD_DAYS: ${RETENTION_PUBLISHED_PAYLOAD_DAYS:-365}
|
||||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
depends_on:
|
depends_on:
|
||||||
db: {condition: service_healthy}
|
db: {condition: service_healthy}
|
||||||
|
|||||||
+16
-4
@@ -81,7 +81,7 @@ curl -fsS https://rf4spotter.ru/ready
|
|||||||
./deploy/backup.sh /srv/rf4-backups
|
./deploy/backup.sh /srv/rf4-backups
|
||||||
```
|
```
|
||||||
|
|
||||||
Восстановление заменяет содержимое PostgreSQL и MinIO данными из выбранной копии, временно останавливая API, web и MinIO. Это намеренно защищённая подтверждением операция:
|
Восстановление заменяет содержимое PostgreSQL и MinIO данными из выбранной копии, временно останавливая API и web. Это намеренно защищённая подтверждением операция:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
CONFIRM_RESTORE=rf4-spotter ./deploy/restore.sh /srv/rf4-backups/20260906T120000Z
|
CONFIRM_RESTORE=rf4-spotter ./deploy/restore.sh /srv/rf4-backups/20260906T120000Z
|
||||||
@@ -96,9 +96,21 @@ curl -fsS https://rf4spotter.ru/ready
|
|||||||
|
|
||||||
Drill успешно пройден 6 сентября 2026 года. На целевом сервере всё равно проведите учебное восстановление с реальной зашифрованной копией перед приглашением пользователей. Затем настройте ежедневный запуск `backup.sh`, выгрузку копий во внешнее хранилище и уведомление при ошибке; храните минимум 7 ежедневных и 4 еженедельных копии.
|
Drill успешно пройден 6 сентября 2026 года. На целевом сервере всё равно проведите учебное восстановление с реальной зашифрованной копией перед приглашением пользователей. Затем настройте ежедневный запуск `backup.sh`, выгрузку копий во внешнее хранилище и уведомление при ошибке; храните минимум 7 ежедневных и 4 еженедельных копии.
|
||||||
|
|
||||||
## 7. Что ещё блокирует приглашение альфа-пользователей
|
## 7. Ежедневное обслуживание
|
||||||
|
|
||||||
- политика хранения и удаления пользовательских данных;
|
После успешного backup сначала проверьте план очистки, затем примените его:
|
||||||
- базовый мониторинг `/ready`, диска и срока TLS-сертификата.
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file .env.production -f compose.production.yaml exec -T api python -m app.cli cleanup-retention
|
||||||
|
docker compose --env-file .env.production -f compose.production.yaml exec -T api python -m app.cli cleanup-retention --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Сроки и состав данных описаны в [`docs/data-retention.md`](../docs/data-retention.md). Автоматизацию включайте только после проверки dry-run на рабочем наборе.
|
||||||
|
|
||||||
|
## 8. Что ещё блокирует приглашение альфа-пользователей
|
||||||
|
|
||||||
|
- базовый мониторинг `/ready`, диска и срока TLS-сертификата;
|
||||||
|
- проверка DNS/TLS и полного запуска на целевом сервере;
|
||||||
|
- внешнее зашифрованное хранилище резервных копий.
|
||||||
|
|
||||||
До закрытия этих пунктов контур можно поднять для технической проверки домена, но не следует открывать форму реальным пользователям.
|
До закрытия этих пунктов контур можно поднять для технической проверки домена, но не следует открывать форму реальным пользователям.
|
||||||
|
|||||||
+5
-6
@@ -65,7 +65,7 @@
|
|||||||
- [ ] Проверить необходимые индексы PostgreSQL и планы запросов для activity, модерации, дедупликации и очистки rate limit; зафиксировать допустимый бюджет запросов пилота.
|
- [ ] Проверить необходимые индексы PostgreSQL и планы запросов для activity, модерации, дедупликации и очистки rate limit; зафиксировать допустимый бюджет запросов пилота.
|
||||||
- [ ] Провести security-проверку admin-аутентификации, CORS, security headers, загрузок и управления секретами; вынести допустимые origins в конфигурацию и исключить демонстрационные секреты в production-режиме.
|
- [ ] Провести security-проверку admin-аутентификации, CORS, security headers, загрузок и управления секретами; вынести допустимые origins в конфигурацию и исключить демонстрационные секреты в production-режиме.
|
||||||
- [x] Проверить авторизацию повторной загрузки скриншота: используется отдельный одноразовый случайный токен, в БД хранится только SHA-256, UUID заявки недостаточно.
|
- [x] Проверить авторизацию повторной загрузки скриншота: используется отдельный одноразовый случайный токен, в БД хранится только SHA-256, UUID заявки недостаточно.
|
||||||
- [ ] Определить сроки хранения ников, исходных payload, staging-наблюдений, moderation events и submission attempts; добавить документированную очистку/анонимизацию.
|
- [x] Определить сроки хранения ников, исходных payload, staging-наблюдений, moderation events и submission attempts; добавлены настраиваемая dry-run-first очистка, тест и `docs/data-retention.md`.
|
||||||
- [x] Добавить резервное копирование и документированное восстановление PostgreSQL и MinIO: консистентные `pg_dump` и MinIO API mirror, контрольные суммы, runbook и успешный изолированный drill с намеренным удалением данных (6 сентября 2026).
|
- [x] Добавить резервное копирование и документированное восстановление PostgreSQL и MinIO: консистентные `pg_dump` и MinIO API mirror, контрольные суммы, runbook и успешный изолированный drill с намеренным удалением данных (6 сентября 2026).
|
||||||
- [ ] Проверить доступность интерфейса: клавиатура, focus states, контраст, подписи полей и семантика таблиц/карточек.
|
- [ ] Проверить доступность интерфейса: клавиатура, focus states, контраст, подписи полей и семантика таблиц/карточек.
|
||||||
- [ ] Провести Lighthouse-проверку основных страниц и устранить критические проблемы производительности.
|
- [ ] Провести Lighthouse-проверку основных страниц и устранить критические проблемы производительности.
|
||||||
@@ -119,11 +119,10 @@
|
|||||||
|
|
||||||
1. защита официального импорта от конкурентных запусков;
|
1. защита официального импорта от конкурентных запусков;
|
||||||
2. пагинация/сортировка списочных API и индексы PostgreSQL;
|
2. пагинация/сортировка списочных API и индексы PostgreSQL;
|
||||||
3. политика хранения и автоматическая очистка персональных/staging данных;
|
3. UI/UX-пакеты B–D: мобильная главная, форма и рекорды;
|
||||||
4. UI/UX-пакеты B–D: мобильная главная, форма и рекорды;
|
4. accessibility/admin safety и Lighthouse;
|
||||||
5. accessibility/admin safety и Lighthouse;
|
5. мониторинг, DNS/TLS и проверка production-профиля на целевом сервере;
|
||||||
6. мониторинг, DNS/TLS и проверка production-профиля на целевом сервере;
|
6. финальное обновление README, лицензия кода и политика данных.
|
||||||
7. финальное обновление README, лицензия кода и политика данных.
|
|
||||||
|
|
||||||
После каждого пункта необходимо:
|
После каждого пункта необходимо:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Политика хранения данных закрытой альфы
|
||||||
|
|
||||||
|
Политика минимизирует персональные и диагностические данные, не разрушая обезличенную статистику клёва. Сроки считаются от `reported_at`, `created_at` или `last_seen_at` соответствующей записи.
|
||||||
|
|
||||||
|
| Данные | Срок | Действие |
|
||||||
|
|---|---:|---|
|
||||||
|
| HMAC-отпечатки IP для rate limit | 1 день | удалить запись |
|
||||||
|
| Pending/rejected пользовательские уловы | 30 дней | стереть ник, комментарий, source URL, токен и скриншот; просроченный pending перевести в rejected с системным событием |
|
||||||
|
| Ник, комментарий и скриншот одобренного пользовательского улова | 180 дней | анонимизировать, сохранив рыбу, точку, вес, снасть и время для агрегатов |
|
||||||
|
| Неопубликованные/rejected staging-наблюдения внешних источников | 90 дней без обновления | удалить запись целиком |
|
||||||
|
| Raw payload опубликованного внешнего наблюдения | 365 дней без обновления | очистить payload, сохранив URL, внешний ID и каноническую связь |
|
||||||
|
| События модерации | 365 дней | удалить событие; сама обезличенная запись улова остаётся |
|
||||||
|
| Официальные рекорды и их публичные поля | пока запись актуальна | сохранять для сверки; удаление регулируется процедурой обновления источника |
|
||||||
|
|
||||||
|
Сроки задаются переменными `RETENTION_*_DAYS` из `.env.production`. Минимальные значения ограничены конфигурацией, чтобы ошибочное значение не вызвало немедленную массовую очистку.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
Сначала обязателен просмотр плана без изменений:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file .env.production -f compose.production.yaml exec -T api python -m app.cli cleanup-retention
|
||||||
|
```
|
||||||
|
|
||||||
|
После проверки счётчиков и свежего backup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file .env.production -f compose.production.yaml exec -T api python -m app.cli cleanup-retention --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Команда выводит JSON с режимом, применёнными сроками и числом затронутых сущностей. Для альфы её следует запускать ежедневно после успешного backup. Ошибка удаления объекта MinIO прерывает обработку базы; повторный запуск безопасен.
|
||||||
|
|
||||||
|
Администратор может вручную удалить пользовательскую заявку раньше срока через очередь модерации. В таком случае ник, исходный payload и скриншот удаляются немедленно, а обезличенный tombstone и событие аудита остаются до штатной очистки.
|
||||||
Reference in New Issue
Block a user