feat: add production data retention

This commit is contained in:
ik
2026-09-06 14:37:58 +07:00
parent 0f075469c8
commit 2200336524
10 changed files with 264 additions and 14 deletions
+8
View File
@@ -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
+5 -3
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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"
+108
View File
@@ -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
+59
View File
@@ -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
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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, лицензия кода и политика данных.
После каждого пункта необходимо: После каждого пункта необходимо:
+33
View File
@@ -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 и событие аудита остаются до штатной очистки.