From 200b81987c9c855c74fbb8eedb57c675efba86d9 Mon Sep 17 00:00:00 2001 From: erjemin Date: Sun, 23 Aug 2026 17:16:06 +0300 Subject: [PATCH] =?UTF-8?q?add:=20Custom=20Command=20=D0=A1=D0=B8=D0=BD?= =?UTF-8?q?=D1=85=D1=80=D0=BE=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20?= =?UTF-8?q?s=5Fhash=5Fid=20=D1=81=20=D1=81=D0=BE=D0=BB=D1=8C=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- _blueprint/management-commands.md | 29 ++++- _blueprint/plan-draft.md | 57 +++++----- .../hypn0_site/management/commands/rehash.py | 105 ++++++++++++++++++ 3 files changed, 162 insertions(+), 29 deletions(-) create mode 100644 hypn0/hypn0_site/management/commands/rehash.py diff --git a/_blueprint/management-commands.md b/_blueprint/management-commands.md index d128b30..4bb69a2 100644 --- a/_blueprint/management-commands.md +++ b/_blueprint/management-commands.md @@ -65,7 +65,34 @@ poetry run python hypn0/manage.py rescore_hypn0_items --dry-run --- -## 2. Планируемые команды (Roadmap) +## 2. `rehash` — Проверка целостности и перегенерация `s_hash_id` + +- **Путь:** `hypn0/hypn0_site/management/commands/rehash.py` +- **Назначение:** Синхронизация публичных коротких хэшей `s_hash_id` в базе данных с текущим значением `HASHIDS_SALT` и `HASHIDS_MIN_LENGTH`. + +### Когда требуется запуск: +1. При смене или ротации `HASHIDS_SALT` в `.env` / `settings.py`. +2. При переносе/миграции базы данных между разными окружениями (Dev $\rightarrow$ Prod). +3. При обнаружении записей с пустыми или устаревшими `s_hash_id`. + +### Базовый запуск: +```bash +# Тестовая проверка нестыковок (без записи в БД) +poetry run python hypn0/manage.py rehash --dry-run + +# Боевая перегенерация +poetry run python hypn0/manage.py rehash +``` + +### Параметры командной строки (CLI): +| Флаг | Тип | По умолчанию | Описание | +|----------------|--------|--------------|----------------------------------------------------------------| +| `--dry-run` | `flag` | `False` | Только проверить нестыковки без изменения базы данных | +| `--batch-size` | `int` | `500` | Размер пачки для `bulk_update` (оптимизация памяти и скорости) | + +--- + +## 3. Планируемые команды (Roadmap) - **`cleanup_storage`** — Удаление старых и низкорейтинговых картин (Smart Retention) при превышении квоты хранилища (использует рассчитанный `f_score`). - **`cleanup_reports`** — Автоматическая изоляция или удаление картин со статусом `SHAMED` после истечения тайм-аута модерации. diff --git a/_blueprint/plan-draft.md b/_blueprint/plan-draft.md index 88177e7..a020cf7 100644 --- a/_blueprint/plan-draft.md +++ b/_blueprint/plan-draft.md @@ -558,34 +558,35 @@ CLI больше не нужен. ## Файлы для создания (итого) -| Файл | Назначение | -|-----------------------------------------------------------|------------------------| -| `hypn0/manage.py` | Django CLI | -| `hypn0/hypn0/__init__.py` | Settings pkg | -| `hypn0/hypn0/settings.py` | Конфиг | -| `hypn0/hypn0/urls.py` | URL routing | -| `hypn0/hypn0/wsgi.py` | WSGI entry | -| `hypn0/hypn0/templates/base.html` | Базовый шаблон | -| `hypn0/hypn0_site/__init__.py` | App pkg | -| `hypn0/hypn0_site/urls.py` | URL app | -| `hypn0/hypn0_site/views.py` | Views | -| `hypn0/hypn0_site/forms.py` | Forms | -| `hypn0/hypn0_site/models.py` | TbHypn0Item, TbVote... | -| `hypn0/hypn0_site/services/halftone.py` | Ядро | -| `hypn0/hypn0_site/templates/halftone/index.html` | Главная | -| `hypn0/hypn0_site/management/commands/rescore_hypn0_items.py` | Скоринг и детекция аномалий | -| `hypn0/hypn0_site/management/commands/cleanup_storage.py` | Очистка SmartRetention | -| `hypn0/hypn0_site/management/commands/cleanup_reports.py` | Очистка по жалобам | -| `_blueprint/management-commands.md` | Документация CLI-команд| -| `public/static/css/site.css` | Стили | -| `public/static/js/site.js` | JS | -| `.env.sample` | Env template | -| `Dockerfile` | Сборка | -| `docker-compose.local.yml` | Dev | -| `docker-compose.prod.yml` | Prod | -| `config/nginx/hypn0-app--external-nginx.conf` | Nginx | -| `.gitea/workflows/docker-publish.yaml` | CI/CD | -| `database/.gitignore` | Игнор БД | +| Файл | Назначение | +|---------------------------------------------------------------|---------------------------------| +| `hypn0/manage.py` | Django CLI | +| `hypn0/hypn0/__init__.py` | Settings pkg | +| `hypn0/hypn0/settings.py` | Конфиг | +| `hypn0/hypn0/urls.py` | URL routing | +| `hypn0/hypn0/wsgi.py` | WSGI entry | +| `hypn0/hypn0/templates/base.html` | Базовый шаблон | +| `hypn0/hypn0_site/__init__.py` | App pkg | +| `hypn0/hypn0_site/urls.py` | URL app | +| `hypn0/hypn0_site/views.py` | Views | +| `hypn0/hypn0_site/forms.py` | Forms | +| `hypn0/hypn0_site/models.py` | TbHypn0Item, TbVote... | +| `hypn0/hypn0_site/services/halftone.py` | Ядро | +| `hypn0/hypn0_site/templates/halftone/index.html` | Главная | +| `hypn0/hypn0_site/management/commands/rescore_hypn0_items.py` | Скоринг и детекция аномалий | +| `hypn0/hypn0_site/management/commands/rehash.py` | Синхронизация s_hash_id с солью | +| `hypn0/hypn0_site/management/commands/cleanup_storage.py` | Очистка SmartRetention | +| `hypn0/hypn0_site/management/commands/cleanup_reports.py` | Очистка по жалобам | +| `_blueprint/management-commands.md` | Документация CLI-команд | +| `public/static/css/site.css` | Стили | +| `public/static/js/site.js` | JS | +| `.env.sample` | Env template | +| `Dockerfile` | Сборка | +| `docker-compose.local.yml` | Dev | +| `docker-compose.prod.yml` | Prod | +| `config/nginx/hypn0-app--external-nginx.conf` | Nginx | +| `.gitea/workflows/docker-publish.yaml` | CI/CD | +| `database/.gitignore` | Игнор БД | ## Файлы для модификации diff --git a/hypn0/hypn0_site/management/commands/rehash.py b/hypn0/hypn0_site/management/commands/rehash.py new file mode 100644 index 0000000..98e4a04 --- /dev/null +++ b/hypn0/hypn0_site/management/commands/rehash.py @@ -0,0 +1,105 @@ +from django.core.management.base import BaseCommand +from django.conf import settings +from hashids import Hashids + +from hypn0_site.models import TbHypn0Item + + +class Command(BaseCommand): + """ + Проверка и синхронизация s_hash_id с текущими настройками HASHIDS_SALT. + + АРХИТЕКТУРНОЕ НАЗНАЧЕНИЕ: + ========================= + 1. Изоляция сред (Dev / Stage / Prod) и ротация секретов: + В разных окружениях или при переносе базы данных значение HASHIDS_SALT может + отличаться от исходного. + + 2. Детерминированность и обратимость: + Hashids — это однозначное обратимое кодирование: pk <-> s_hash_id. + Если соль изменилась, старые s_hash_id в базе перестают соответствовать + текущей конфигурации проекта. + + 3. Назначение команды: + - Сверяет каждый s_hash_id в базе с тем, что должен генерироваться при текущем settings.HASHIDS_SALT. + - Выявляет битые, пустые или сгенерированные со старой солью идентификаторы. + - Безопасно обновляет s_hash_id без изменения первичных ключей (pk) и других полей картины. + """ + + help = "Проверка целостности и перегенерация s_hash_id картин под текущий HASHIDS_SALT" + + def add_arguments(self, parser): + parser.add_argument( + "--dry-run", + action="store_true", + help="Тестовый прогон: только проверить несоответствия без записи изменений в БД", + ) + parser.add_argument( + "--batch-size", + type=int, + default=500, + help="Размер пакета для пакетного обновления (по умолчанию: 500)", + ) + + def handle(self, *args, **options): + dry_run = options["dry_run"] + batch_size = options["batch_size"] + + salt = getattr(settings, "HASHIDS_SALT", "hypn0_default_salt") + min_length = getattr(settings, "HASHIDS_MIN_LENGTH", 6) + hasher = Hashids(salt=salt, min_length=min_length) + + self.stdout.write(self.style.NOTICE(f"Старт проверки s_hash_id (соль: '{salt[:4]}***', min_length: {min_length})...")) + + items = TbHypn0Item.objects.all().only("id", "s_hash_id") + total_items = items.count() + + if total_items == 0: + self.stdout.write(self.style.WARNING("В базе данных нет картин для проверки.")) + return + + to_update = [] + mismatched_count = 0 + empty_count = 0 + + for item in items.iterator(chunk_size=batch_size): + expected_hash = hasher.encode(item.id) + + if not item.s_hash_id: + empty_count += 1 + item.s_hash_id = expected_hash + to_update.append(item) + elif item.s_hash_id != expected_hash: + mismatched_count += 1 + self.stdout.write( + f"Несоответствие [ID={item.id}]: текущий='{item.s_hash_id}' -> ожидаемый='{expected_hash}'" + ) + item.s_hash_id = expected_hash + to_update.append(item) + + # Пакетное сохранение порциями + if not dry_run and len(to_update) >= batch_size: + TbHypn0Item.objects.bulk_update(to_update, fields=["s_hash_id"]) + to_update.clear() + + # Сохранение остатка + if not dry_run and to_update: + TbHypn0Item.objects.bulk_update(to_update, fields=["s_hash_id"]) + + total_issues = empty_count + mismatched_count + self.stdout.write("--------------------------------------------------") + self.stdout.write(f"Всего проверено записей: {total_items}") + self.stdout.write(f"Пустых s_hash_id: {empty_count}") + self.stdout.write(f"Несоответствующих s_hash_id: {mismatched_count}") + + if total_issues == 0: + self.stdout.write(self.style.SUCCESS("Все s_hash_id в базе идеально соответствуют текущей соли!")) + else: + if dry_run: + self.stdout.write( + self.style.WARNING(f"[DRY RUN] Найдено {total_issues} несоответствий. Запустите без --dry-run для применения.") + ) + else: + self.stdout.write( + self.style.SUCCESS(f"Успешно обновлено {total_issues} записей под текущую конфигурацию.") + )