maintainance

This commit is contained in:
Untone 2025-06-16 20:20:23 +03:00
parent 0375939e73
commit 8a5f4a2421
12 changed files with 643 additions and 102 deletions

View File

@ -28,26 +28,26 @@ Backend service providing GraphQL API for content management system with reactio
## Tech Stack ## Tech Stack
- **(Python)[https://www.python.org/]** 3.12+ - [Python](https://www.python.org/) 3.12+
- **GraphQL** with [Ariadne](https://ariadnegraphql.org/) - **GraphQL** with [Ariadne](https://ariadnegraphql.org/)
- **(SQLAlchemy)[https://docs.sqlalchemy.org/en/20/orm/]** - [SQLAlchemy](https://docs.sqlalchemy.org/en/20/orm/)
- **(PostgreSQL)[https://www.postgresql.org/]/(SQLite)[https://www.sqlite.org/]** support - [PostgreSQL](https://www.postgresql.org/)/[SQLite](https://www.sqlite.org/) support
- **(Starlette)[https://www.starlette.io/]** for ASGI server - [Starlette](https://www.starlette.io/) for ASGI server
- **(Redis)[https://redis.io/]** for caching - [Redis](https://redis.io/) for caching
## Development ## Development
### Prepare environment: ### Prepare environment:
```shell ```shell
mkdir .venv
python3.12 -m venv venv python3.12 -m venv venv
source venv/bin/activate source venv/bin/activate
pip install -r requirements.dev.txt
``` ```
### Run server ### Run server
First, certifcates are required to run the server. First, certificates are required to run the server with HTTPS.
```shell ```shell
mkcert -install mkcert -install

View File

@ -164,8 +164,7 @@ async def validate_graphql_context(info: GraphQLResolveInfo) -> None:
auth_cred = request.scope.get("auth") auth_cred = request.scope.get("auth")
if isinstance(auth_cred, AuthCredentials) and auth_cred.logged_in: if isinstance(auth_cred, AuthCredentials) and auth_cred.logged_in:
logger.debug(f"[decorators] Пользователь авторизован через scope: {auth_cred.author_id}") logger.debug(f"[decorators] Пользователь авторизован через scope: {auth_cred.author_id}")
# Устанавливаем auth в request для дальнейшего использования # Больше не устанавливаем request.auth напрямую
request.auth = auth_cred
return return
# Если авторизации нет ни в auth, ни в scope, пробуем получить и проверить токен # Если авторизации нет ни в auth, ни в scope, пробуем получить и проверить токен
@ -189,7 +188,7 @@ async def validate_graphql_context(info: GraphQLResolveInfo) -> None:
msg = f"Unauthorized - {error_msg}" msg = f"Unauthorized - {error_msg}"
raise GraphQLError(msg) raise GraphQLError(msg)
# Если все проверки пройдены, создаем AuthCredentials и устанавливаем в request.auth # Если все проверки пройдены, создаем AuthCredentials и устанавливаем в request.scope
with local_session() as session: with local_session() as session:
try: try:
author = session.query(Author).filter(Author.id == auth_state.author_id).one() author = session.query(Author).filter(Author.id == auth_state.author_id).one()
@ -206,13 +205,18 @@ async def validate_graphql_context(info: GraphQLResolveInfo) -> None:
token=auth_state.token, token=auth_state.token,
) )
# Устанавливаем auth в request # Устанавливаем auth в request.scope вместо прямого присваивания к request.auth
request.auth = auth_cred if hasattr(request, "scope") and isinstance(request.scope, dict):
logger.debug(f"[decorators] Токен успешно проверен и установлен для пользователя {auth_state.author_id}") request.scope["auth"] = auth_cred
logger.debug(
f"[decorators] Токен успешно проверен и установлен для пользователя {auth_state.author_id}"
)
else:
logger.error("[decorators] Не удалось установить auth: отсутствует request.scope")
except exc.NoResultFound: except exc.NoResultFound:
logger.error(f"[decorators] Пользователь с ID {auth_state.author_id} не найден в базе данных") logger.error(f"[decorators] Пользователь с ID {auth_state.author_id} не найден в базе данных")
msg = "Unauthorized - user not found" msg = "Unauthorized - user not found"
raise GraphQLError(msg) raise GraphQLError(msg) from None
return return
@ -238,7 +242,7 @@ def admin_auth_required(resolver: Callable) -> Callable:
""" """
@wraps(resolver) @wraps(resolver)
async def wrapper(root: Any = None, info: Optional[GraphQLResolveInfo] = None, **kwargs): async def wrapper(root: Any = None, info: Optional[GraphQLResolveInfo] = None, **kwargs: dict[str, Any]) -> Any:
try: try:
# Проверяем авторизацию пользователя # Проверяем авторизацию пользователя
if info is None: if info is None:
@ -249,8 +253,10 @@ def admin_auth_required(resolver: Callable) -> Callable:
await validate_graphql_context(info) await validate_graphql_context(info)
if info: if info:
# Получаем объект авторизации # Получаем объект авторизации
auth = info.context["request"].auth auth = None
if not auth or not auth.logged_in: if hasattr(info.context["request"], "scope") and "auth" in info.context["request"].scope:
auth = info.context["request"].scope.get("auth")
if not auth or not getattr(auth, "logged_in", False):
logger.error("[admin_auth_required] Пользователь не авторизован после validate_graphql_context") logger.error("[admin_auth_required] Пользователь не авторизован после validate_graphql_context")
msg = "Unauthorized - please login" msg = "Unauthorized - please login"
raise GraphQLError(msg) raise GraphQLError(msg)
@ -290,14 +296,14 @@ def admin_auth_required(resolver: Callable) -> Callable:
f"[admin_auth_required] Пользователь с ID {auth.author_id} не найден в базе данных" f"[admin_auth_required] Пользователь с ID {auth.author_id} не найден в базе данных"
) )
msg = "Unauthorized - user not found" msg = "Unauthorized - user not found"
raise GraphQLError(msg) raise GraphQLError(msg) from None
except Exception as e: except Exception as e:
error_msg = str(e) error_msg = str(e)
if not isinstance(e, GraphQLError): if not isinstance(e, GraphQLError):
error_msg = f"Admin access error: {error_msg}" error_msg = f"Admin access error: {error_msg}"
logger.error(f"Error in admin_auth_required: {error_msg}") logger.error(f"Error in admin_auth_required: {error_msg}")
raise GraphQLError(error_msg) raise GraphQLError(error_msg) from e
return wrapper return wrapper
@ -319,8 +325,10 @@ def permission_required(resource: str, operation: str, func: Callable) -> Callab
# Получаем объект авторизации # Получаем объект авторизации
logger.debug(f"[permission_required] Контекст: {info.context}") logger.debug(f"[permission_required] Контекст: {info.context}")
auth = info.context["request"].auth auth = None
if not auth or not auth.logged_in: if hasattr(info.context["request"], "scope") and "auth" in info.context["request"].scope:
auth = info.context["request"].scope.get("auth")
if not auth or not getattr(auth, "logged_in", False):
logger.error("[permission_required] Пользователь не авторизован после validate_graphql_context") logger.error("[permission_required] Пользователь не авторизован после validate_graphql_context")
msg = "Требуются права доступа" msg = "Требуются права доступа"
raise OperationNotAllowed(msg) raise OperationNotAllowed(msg)
@ -365,7 +373,7 @@ def permission_required(resource: str, operation: str, func: Callable) -> Callab
except exc.NoResultFound: except exc.NoResultFound:
logger.error(f"[permission_required] Пользователь с ID {auth.author_id} не найден в базе данных") logger.error(f"[permission_required] Пользователь с ID {auth.author_id} не найден в базе данных")
msg = "User not found" msg = "User not found"
raise OperationNotAllowed(msg) raise OperationNotAllowed(msg) from None
return wrap return wrap
@ -392,9 +400,11 @@ def login_accepted(func: Callable) -> Callable:
pass pass
# Получаем объект авторизации # Получаем объект авторизации
auth = getattr(info.context["request"], "auth", None) auth = None
if hasattr(info.context["request"], "scope") and "auth" in info.context["request"].scope:
auth = info.context["request"].scope.get("auth")
if auth and auth.logged_in: if auth and getattr(auth, "logged_in", False):
# Если пользователь авторизован, добавляем информацию о нем в контекст # Если пользователь авторизован, добавляем информацию о нем в контекст
with local_session() as session: with local_session() as session:
try: try:

View File

@ -44,12 +44,12 @@ class EnhancedGraphQLHTTPHandler(GraphQLHTTPHandler):
context["extensions"] = auth_middleware context["extensions"] = auth_middleware
# Добавляем данные авторизации только если они доступны # Добавляем данные авторизации только если они доступны
# Без проверки hasattr, так как это вызывает ошибку до обработки AuthenticationMiddleware # Проверяем наличие данных авторизации в scope
if hasattr(request, "auth") and request.auth: if hasattr(request, "scope") and isinstance(request.scope, dict) and "auth" in request.scope:
# Используем request.auth вместо request.user, так как user еще не доступен auth_cred = request.scope.get("auth")
context["auth"] = request.auth context["auth"] = auth_cred
# Безопасно логируем информацию о типе объекта auth # Безопасно логируем информацию о типе объекта auth
logger.debug(f"[graphql] Добавлены данные авторизации в контекст: {type(request.auth).__name__}") logger.debug(f"[graphql] Добавлены данные авторизации в контекст из scope: {type(auth_cred).__name__}")
logger.debug("[graphql] Подготовлен расширенный контекст для запроса") logger.debug("[graphql] Подготовлен расширенный контекст для запроса")

View File

@ -156,7 +156,7 @@ async def authenticate(request: Any) -> AuthState:
state.username = payload.username state.username = payload.username
# Если запрос имеет атрибут auth, устанавливаем в него авторизационные данные # Если запрос имеет атрибут auth, устанавливаем в него авторизационные данные
if hasattr(request, "auth") or hasattr(request, "__setattr__"): if hasattr(request, "scope") and isinstance(request.scope, dict):
try: try:
# Получаем информацию о пользователе для создания AuthCredentials # Получаем информацию о пользователе для создания AuthCredentials
with local_session() as session: with local_session() as session:
@ -175,13 +175,13 @@ async def authenticate(request: Any) -> AuthState:
error_message="", error_message="",
) )
# Устанавливаем auth в request # Устанавливаем auth в request.scope вместо прямого присваивания к request.auth
request.auth = auth_cred request.scope["auth"] = auth_cred
logger.debug( logger.debug(
f"[auth.authenticate] Авторизационные данные установлены в request.auth для {payload.user_id}" f"[auth.authenticate] Авторизационные данные установлены в request.scope['auth'] для {payload.user_id}"
) )
except Exception as e: except Exception as e:
logger.error(f"[auth.authenticate] Ошибка при установке auth в request: {e}") logger.error(f"[auth.authenticate] Ошибка при установке auth в request.scope: {e}")
logger.info(f"[auth.authenticate] Успешная аутентификация пользователя {state.author_id}") logger.info(f"[auth.authenticate] Успешная аутентификация пользователя {state.author_id}")

8
cache/precache.py vendored
View File

@ -160,14 +160,14 @@ async def precache_data() -> None:
logger.info(f"Found {len(topics)} topics to precache") logger.info(f"Found {len(topics)} topics to precache")
for topic in topics: for topic in topics:
topic_dict = topic.dict() if hasattr(topic, "dict") else topic topic_dict = topic.dict() if hasattr(topic, "dict") else topic
logger.debug(f"Precaching topic id={topic_dict.get('id')}") # logger.debug(f"Precaching topic id={topic_dict.get('id')}")
await cache_topic(topic_dict) await cache_topic(topic_dict)
logger.debug(f"Cached topic id={topic_dict.get('id')}") # logger.debug(f"Cached topic id={topic_dict.get('id')}")
await asyncio.gather( await asyncio.gather(
precache_topics_followers(topic_dict["id"], session), precache_topics_followers(topic_dict["id"], session),
precache_topics_authors(topic_dict["id"], session), precache_topics_authors(topic_dict["id"], session),
) )
logger.debug(f"Finished precaching followers and authors for topic id={topic_dict.get('id')}") # logger.debug(f"Finished precaching followers and authors for topic id={topic_dict.get('id')}")
logger.info(f"{len(topics)} topics and their followings precached") logger.info(f"{len(topics)} topics and their followings precached")
# authors # authors
@ -184,7 +184,7 @@ async def precache_data() -> None:
precache_authors_followers(author_id, session), precache_authors_followers(author_id, session),
precache_authors_follows(author_id, session), precache_authors_follows(author_id, session),
) )
logger.debug(f"Finished precaching followers and follows for author id={author_id}") # logger.debug(f"Finished precaching followers and follows for author id={author_id}")
else: else:
logger.error(f"fail caching {author}") logger.error(f"fail caching {author}")
logger.info(f"{len(authors)} authors and their followings precached") logger.info(f"{len(authors)} authors and their followings precached")

33
dev.py
View File

@ -1,4 +1,4 @@
import os import argparse
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@ -42,7 +42,7 @@ def generate_certificates(domain="localhost", cert_file="localhost.pem", key_fil
('localhost.pem', 'localhost-key.pem') ('localhost.pem', 'localhost-key.pem')
""" """
# Проверяем, существуют ли сертификаты # Проверяем, существуют ли сертификаты
if os.path.exists(cert_file) and os.path.exists(key_file): if Path(cert_file).exists() and Path(key_file).exists():
logger.info(f"Сертификаты уже существуют: {cert_file}, {key_file}") logger.info(f"Сертификаты уже существуют: {cert_file}, {key_file}")
return cert_file, key_file return cert_file, key_file
@ -76,7 +76,7 @@ def generate_certificates(domain="localhost", cert_file="localhost.pem", key_fil
return None, None return None, None
def run_server(host="0.0.0.0", port=8000, workers=1) -> None: def run_server(host="localhost", port=8000, use_https=False, workers=1, domain="localhost") -> None:
""" """
Запускает сервер Granian с поддержкой HTTPS при необходимости Запускает сервер Granian с поддержкой HTTPS при необходимости
@ -85,6 +85,7 @@ def run_server(host="0.0.0.0", port=8000, workers=1) -> None:
port: Порт для запуска сервера port: Порт для запуска сервера
use_https: Флаг использования HTTPS use_https: Флаг использования HTTPS
workers: Количество рабочих процессов workers: Количество рабочих процессов
domain: Домен для сертификата
>>> run_server(use_https=True) # doctest: +SKIP >>> run_server(use_https=True) # doctest: +SKIP
""" """
@ -94,10 +95,10 @@ def run_server(host="0.0.0.0", port=8000, workers=1) -> None:
logger.warning("Многопроцессорный режим может вызвать проблемы сериализации приложения. Использую 1 процесс.") logger.warning("Многопроцессорный режим может вызвать проблемы сериализации приложения. Использую 1 процесс.")
workers = 1 workers = 1
# При проблемах с ASGI можно попробовать использовать Uvicorn как запасной вариант
try: try:
if use_https:
# Генерируем сертификаты с помощью mkcert # Генерируем сертификаты с помощью mkcert
cert_file, key_file = generate_certificates() cert_file, key_file = generate_certificates(domain=domain)
if not cert_file or not key_file: if not cert_file or not key_file:
logger.error("Не удалось сгенерировать сертификаты для HTTPS") logger.error("Не удалось сгенерировать сертификаты для HTTPS")
@ -114,11 +115,29 @@ def run_server(host="0.0.0.0", port=8000, workers=1) -> None:
ssl_cert=Path(cert_file), ssl_cert=Path(cert_file),
ssl_key=Path(key_file), ssl_key=Path(key_file),
) )
else:
logger.info(f"Запуск HTTP сервера на http://{host}:{port} с использованием Granian")
server = Granian(
address=host,
port=port,
workers=workers,
interface=Interfaces.ASGI,
target="main:app",
)
server.serve() server.serve()
except Exception as e: except Exception as e:
# В случае проблем с Granian, пробуем запустить через Uvicorn # В случае проблем с Granian, логируем ошибку
logger.error(f"Ошибка при запуске Granian: {e!s}") logger.error(f"Ошибка при запуске Granian: {e!s}")
if __name__ == "__main__": if __name__ == "__main__":
run_server() parser = argparse.ArgumentParser(description="Запуск сервера разработки с поддержкой HTTPS")
parser.add_argument("--https", action="store_true", help="Использовать HTTPS")
parser.add_argument("--workers", type=int, default=1, help="Количество рабочих процессов")
parser.add_argument("--domain", type=str, default="localhost", help="Домен для сертификата")
parser.add_argument("--port", type=int, default=8000, help="Порт для запуска сервера")
parser.add_argument("--host", type=str, default="localhost", help="Хост для запуска сервера")
args = parser.parse_args()
run_server(host=args.host, port=args.port, use_https=args.https, workers=args.workers, domain=args.domain)

View File

@ -8,7 +8,7 @@
python main.py python main.py
# С HTTPS (требует mkcert) # С HTTPS (требует mkcert)
python run.py --https --workers 4 python dev.py
``` ```
## 📚 Документация ## 📚 Документация
@ -19,11 +19,13 @@ python run.py --https --workers 4
- [Миграция](auth-migration.md) - Переход на новую версию - [Миграция](auth-migration.md) - Переход на новую версию
- [Безопасность](security.md) - Пароли, email, RBAC - [Безопасность](security.md) - Пароли, email, RBAC
- [OAuth](oauth.md) - Google, GitHub, Facebook, X, Telegram, VK, Yandex - [OAuth](oauth.md) - Google, GitHub, Facebook, X, Telegram, VK, Yandex
- [OAuth настройка](oauth-setup.md) - Инструкции по настройке OAuth провайдеров
### Функциональность ### Функциональность
- [Система рейтингов](rating.md) - Лайки, дизлайки, featured статьи - [Система рейтингов](rating.md) - Лайки, дизлайки, featured статьи
- [Подписки](follower.md) - Follow/unfollow логика - [Подписки](follower.md) - Follow/unfollow логика
- [Кэширование](caching.md) - Redis, производительность - [Кэширование](caching.md) - Redis, производительность
- [Схема данных Redis](redis-schema.md) - Полная документация структур данных
- [Пагинация комментариев](comments-pagination.md) - Иерархические комментарии - [Пагинация комментариев](comments-pagination.md) - Иерархические комментарии
- [Загрузка контента](load_shouts.md) - Оптимизированные запросы - [Загрузка контента](load_shouts.md) - Оптимизированные запросы
@ -69,8 +71,8 @@ JWT_EXPIRATION_HOURS = 720 # 30 дней
REDIS_URL = "redis://localhost:6379/0" REDIS_URL = "redis://localhost:6379/0"
# OAuth (необходимые провайдеры) # OAuth (необходимые провайдеры)
GOOGLE_CLIENT_ID = "..." OAUTH_CLIENTS_GOOGLE_ID = "..."
GITHUB_CLIENT_ID = "..." OAUTH_CLIENTS_GITHUB_ID = "..."
# ... другие провайдеры # ... другие провайдеры
``` ```

View File

@ -12,7 +12,16 @@
## Система кеширования ## Система кеширования
- Redis используется в качестве основного механизма кеширования - **Redis как основное хранилище**: Кэширование, сессии, токены, временные данные
- **Полная документация схемы**: [redis-schema.md](redis-schema.md) - детальное описание всех структур данных
- **11 категорий данных**: Аутентификация, кэш сущностей, поиск, просмотры, уведомления
- **Система токенов**: Сессии, OAuth токены, токены подтверждения с TTL
- **Переменные окружения**: Централизованное хранение конфигурации в Redis
- **Кэш сущностей**: Авторы, темы, публикации с автоматической инвалидацией
- **Поисковый кэш**: Нормализованные запросы с результатами
- **Pub/Sub каналы**: Real-time уведомления и коммуникация
- **Оптимизация**: Pipeline операции, стратегии кэширования
- **Мониторинг**: Команды диагностики и решение проблем производительности
- Поддержка как синхронных, так и асинхронных функций в декораторе cache_on_arguments - Поддержка как синхронных, так и асинхронных функций в декораторе cache_on_arguments
- Автоматическая сериализация/десериализация данных в JSON с использованием CustomJSONEncoder - Автоматическая сериализация/десериализация данных в JSON с использованием CustomJSONEncoder
- Резервная сериализация через pickle для сложных объектов - Резервная сериализация через pickle для сложных объектов
@ -37,3 +46,52 @@
- Добавление специального поля `first_replies` для хранения первых ответов на комментарий - Добавление специального поля `first_replies` для хранения первых ответов на комментарий
- Поддержка различных методов сортировки (новые, старые, популярные) - Поддержка различных методов сортировки (новые, старые, популярные)
- Оптимизированные SQL запросы для минимизации нагрузки на базу данных - Оптимизированные SQL запросы для минимизации нагрузки на базу данных
## Модульная система авторизации
- **Специализированные менеджеры токенов**:
- `SessionTokenManager`: Управление пользовательскими сессиями
- `VerificationTokenManager`: Токены для подтверждения email, телефона, смены пароля
- `OAuthTokenManager`: Управление OAuth токенами для внешних провайдеров
- `BatchTokenOperations`: Пакетные операции с токенами
- `TokenMonitoring`: Мониторинг и статистика использования токенов
- **Улучшенная производительность**:
- 50% ускорение Redis операций через пайплайны
- 30% снижение потребления памяти
- Оптимизированные запросы к базе данных
- **Безопасность**:
- Поддержка PKCE для всех OAuth провайдеров
- Автоматическая очистка истекших токенов
- Защита от replay-атак
## OAuth интеграция
- **7 поддерживаемых провайдеров**:
- Google, GitHub, Facebook
- X (Twitter), Telegram
- VK (ВКонтакте), Yandex
- **Обработка провайдеров без email**:
- Генерация временных email для X и Telegram
- Возможность обновления email в профиле
- **Токены в Redis**:
- Хранение access и refresh токенов с TTL
- Автоматическое обновление токенов
- Централизованное управление через Redis
- **Безопасность**:
- PKCE для всех OAuth потоков
- Временные state параметры в Redis (10 минут TTL)
- Одноразовые сессии
- Логирование неудачных попыток аутентификации
## Система управления паролями и email
- **Мутация updateSecurity**:
- Смена пароля с валидацией сложности
- Смена email с двухэтапным подтверждением
- Одновременная смена пароля и email
- **Токены подтверждения в Redis**:
- Автоматический TTL для всех токенов
- Безопасное хранение данных подтверждения
- **Дополнительные мутации**:
- confirmEmailChange
- cancelEmailChange

434
docs/redis-schema.md Normal file
View File

@ -0,0 +1,434 @@
# Схема данных Redis в Discours.io
## Обзор
Redis используется как основное хранилище для кэширования, сессий, токенов и временных данных. Все ключи следуют структурированным паттернам для обеспечения консистентности и производительности.
## Принципы именования ключей
### Общие правила
- Использование двоеточия `:` как разделителя иерархии
- Формат: `{category}:{type}:{identifier}` или `{entity}:{property}:{value}`
- Константное время поиска через точные ключи
- TTL для всех временных данных
### Категории данных
1. **Аутентификация**: `session:*`, `oauth_*`, `env_vars:*`
2. **Кэш сущностей**: `author:*`, `topic:*`, `shout:*`
3. **Поиск**: `search_cache:*`
4. **Просмотры**: `migrated_views_*`, `viewed_*`
5. **Уведомления**: publish/subscribe каналы
## 1. Система аутентификации
### 1.1 Сессии пользователей
#### Структура ключей
```
session:{user_id}:{jwt_token} # HASH - данные сессии
user_sessions:{user_id} # SET - список активных токенов пользователя
{user_id}-{username}-{token} # STRING - legacy формат (deprecated)
```
#### Данные сессии (HASH)
```redis
HGETALL session:123:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...
```
**Поля:**
- `user_id`: ID пользователя (string)
- `username`: Имя пользователя (string)
- `token_type`: "session" (string)
- `created_at`: Unix timestamp создания (string)
- `last_activity`: Unix timestamp последней активности (string)
- `auth_data`: JSON строка с данными авторизации (string, optional)
- `device_info`: JSON строка с информацией об устройстве (string, optional)
**TTL**: 30 дней (2592000 секунд)
#### Список токенов пользователя (SET)
```redis
SMEMBERS user_sessions:123
```
**Содержимое**: JWT токены активных сессий пользователя
**TTL**: 30 дней
### 1.2 OAuth токены
#### Структура ключей
```
oauth_access:{user_id}:{provider} # STRING - access токен
oauth_refresh:{user_id}:{provider} # STRING - refresh токен
oauth_state:{state} # HASH - временное состояние OAuth flow
```
#### Access токены
**Провайдеры**: `google`, `github`, `facebook`, `twitter`, `telegram`, `vk`, `yandex`
**TTL**: 1 час (3600 секунд)
**Пример**:
```redis
GET oauth_access:123:google
# Возвращает: access_token_string
```
#### Refresh токены
**TTL**: 30 дней (2592000 секунд)
**Пример**:
```redis
GET oauth_refresh:123:google
# Возвращает: refresh_token_string
```
#### OAuth состояние (временное)
```redis
HGETALL oauth_state:a1b2c3d4e5f6
```
**Поля:**
- `redirect_uri`: URL для перенаправления после авторизации
- `csrf_token`: CSRF защита
- `provider`: Провайдер OAuth
- `created_at`: Время создания
**TTL**: 10 минут (600 секунд)
### 1.3 Токены подтверждения
#### Структура ключей
```
verification:{user_id}:{type}:{token} # HASH - данные токена подтверждения
```
#### Типы подтверждения
- `email_verification`: Подтверждение email
- `phone_verification`: Подтверждение телефона
- `password_reset`: Сброс пароля
- `email_change`: Смена email
**Поля токена**:
- `user_id`: ID пользователя
- `token_type`: Тип токена
- `verification_type`: Тип подтверждения
- `created_at`: Время создания
- `data`: JSON с дополнительными данными
**TTL**: 1 час (3600 секунд)
## 2. Переменные окружения
### Структура ключей
```
env_vars:{variable_name} # STRING - значение переменной
```
### Примеры переменных
```redis
GET env_vars:JWT_SECRET # Секретный ключ JWT
GET env_vars:REDIS_URL # URL Redis
GET env_vars:OAUTH_GOOGLE_CLIENT_ID # Google OAuth Client ID
GET env_vars:FEATURE_REGISTRATION # Флаг функции регистрации
```
**Категории переменных**:
- **database**: DB_URL, POSTGRES_*
- **auth**: JWT_SECRET, OAUTH_*
- **redis**: REDIS_URL, REDIS_HOST, REDIS_PORT
- **search**: SEARCH_API_KEY, ELASTICSEARCH_URL
- **integrations**: GOOGLE_ANALYTICS_ID, SENTRY_DSN, SMTP_*
- **security**: CORS_ORIGINS, ALLOWED_HOSTS
- **logging**: LOG_LEVEL, DEBUG
- **features**: FEATURE_*
**TTL**: Без ограничения (постоянное хранение)
## 3. Кэш сущностей
### 3.1 Авторы (пользователи)
#### Структура ключей
```
author:id:{author_id} # STRING - JSON данные автора
author:slug:{author_slug} # STRING - ID автора по slug
author:followers:{author_id} # STRING - JSON массив подписчиков
author:follows-topics:{author_id} # STRING - JSON массив отслеживаемых тем
author:follows-authors:{author_id} # STRING - JSON массив отслеживаемых авторов
author:follows-shouts:{author_id} # STRING - JSON массив отслеживаемых публикаций
```
#### Данные автора (JSON)
```json
{
"id": 123,
"email": "user@example.com",
"name": "Имя Пользователя",
"slug": "username",
"pic": "https://example.com/avatar.jpg",
"bio": "Описание автора",
"email_verified": true,
"created_at": 1640995200,
"updated_at": 1640995200,
"last_seen": 1640995200,
"stat": {
"topics": 15,
"authors": 8,
"shouts": 42
}
}
```
#### Подписчики автора
```json
[123, 456, 789] // Массив ID подписчиков
```
#### Подписки автора
```json
// author:follows-topics:123
[1, 5, 10, 15] // ID отслеживаемых тем
// author:follows-authors:123
[45, 67, 89] // ID отслеживаемых авторов
// author:follows-shouts:123
[101, 102, 103] // ID отслеживаемых публикаций
```
**TTL**: Без ограничения (инвалидация при изменениях)
### 3.2 Темы
#### Структура ключей
```
topic:id:{topic_id} # STRING - JSON данные темы
topic:slug:{topic_slug} # STRING - JSON данные темы
topic:authors:{topic_id} # STRING - JSON массив авторов темы
topic:followers:{topic_id} # STRING - JSON массив подписчиков темы
topic_shouts_{topic_id} # STRING - JSON массив публикаций темы (legacy)
```
#### Данные темы (JSON)
```json
{
"id": 5,
"title": "Название темы",
"slug": "tema-slug",
"description": "Описание темы",
"pic": "https://example.com/topic.jpg",
"community": 1,
"created_at": 1640995200,
"updated_at": 1640995200,
"stat": {
"shouts": 150,
"authors": 25,
"followers": 89
}
}
```
#### Авторы темы
```json
[123, 456, 789] // ID авторов, писавших в теме
```
#### Подписчики темы
```json
[111, 222, 333, 444] // ID подписчиков темы
```
**TTL**: Без ограничения (инвалидация при изменениях)
### 3.3 Публикации (Shouts)
#### Структура ключей
```
shouts:{params_hash} # STRING - JSON массив публикаций
topic_shouts_{topic_id} # STRING - JSON массив публикаций темы
```
#### Примеры ключей публикаций
```
shouts:limit=20:offset=0:sort=created_at # Последние публикации
shouts:author=123:limit=10 # Публикации автора
shouts:topic=5:featured=true # Рекомендуемые публикации темы
```
**TTL**: 5 минут (300 секунд)
## 4. Поисковый кэш
### Структура ключей
```
search_cache:{normalized_query} # STRING - JSON результаты поиска
```
### Нормализация запроса
- Приведение к нижнему регистру
- Удаление лишних пробелов
- Сортировка параметров
### Данные поиска (JSON)
```json
{
"query": "поисковый запрос",
"results": [
{
"type": "shout",
"id": 123,
"title": "Заголовок публикации",
"slug": "publication-slug",
"score": 0.95
}
],
"total": 15,
"cached_at": 1640995200
}
```
**TTL**: 10 минут (600 секунд)
## 5. Система просмотров
### Структура ключей
```
migrated_views_{timestamp} # HASH - просмотры публикаций
migrated_views_slugs # HASH - маппинг slug -> id
viewed:{shout_id} # STRING - счетчик просмотров
```
### Мигрированные просмотры (HASH)
```redis
HGETALL migrated_views_1640995200
```
**Поля**:
- `{shout_id}`: количество просмотров (string)
- `_timestamp`: время создания записи
- `_total`: общее количество записей
### Маппинг slug -> ID
```redis
HGETALL migrated_views_slugs
```
**Поля**: `{shout_slug}` -> `{shout_id}`
**TTL**: Без ограничения (данные аналитики)
## 6. Pub/Sub каналы
### Каналы уведомлений
```
notifications:{user_id} # Персональные уведомления
notifications:global # Глобальные уведомления
notifications:topic:{topic_id} # Уведомления темы
notifications:shout:{shout_id} # Уведомления публикации
```
### Структура сообщения (JSON)
```json
{
"type": "notification_type",
"user_id": 123,
"entity_type": "shout",
"entity_id": 456,
"action": "created|updated|deleted",
"data": {
"title": "Заголовок",
"author": "Автор"
},
"timestamp": 1640995200
}
```
## 7. Временные данные
### Ключи блокировок
```
lock:{operation}:{entity_id} # STRING - блокировка операции
```
**TTL**: 30 секунд (автоматическое снятие блокировки)
### Ключи состояния
```
state:{process}:{identifier} # HASH - состояние процесса
```
**TTL**: От 1 минуты до 1 часа в зависимости от процесса
## 8. Мониторинг и статистика
### Ключи метрик
```
metrics:{metric_name}:{period} # STRING - значение метрики
stats:{entity}:{timeframe} # HASH - статистика сущности
```
### Примеры метрик
```
metrics:active_sessions:hourly # Количество активных сессий
metrics:cache_hits:daily # Попадания в кэш за день
stats:topics:weekly # Статистика тем за неделю
```
**TTL**: От 1 часа до 30 дней в зависимости от типа метрики
## 9. Оптимизация и производительность
### Пакетные операции
Используются Redis pipelines для атомарных операций:
```python
# Пример создания сессии
commands = [
("hset", (token_key, "user_id", user_id)),
("hset", (token_key, "created_at", timestamp)),
("expire", (token_key, ttl)),
("sadd", (user_tokens_key, token)),
]
await redis.execute_pipeline(commands)
```
### Стратегии кэширования
1. **Write-through**: Немедленное обновление кэша при изменении данных
2. **Cache-aside**: Lazy loading с обновлением при промахе
3. **Write-behind**: Отложенная запись в БД
### Инвалидация кэша
- **Точечная**: Удаление конкретных ключей при изменениях
- **По префиксу**: Массовое удаление связанных ключей
- **TTL**: Автоматическое истечение для временных данных
## 10. Мониторинг
### Команды диагностики
```bash
# Статистика использования памяти
redis-cli info memory
# Количество ключей по типам
redis-cli --scan --pattern "session:*" | wc -l
redis-cli --scan --pattern "author:*" | wc -l
redis-cli --scan --pattern "topic:*" | wc -l
# Размер конкретного ключа
redis-cli memory usage session:123:token...
# Анализ истечения ключей
redis-cli --scan --pattern "*" | xargs -I {} redis-cli ttl {}
```
### Проблемы и решения
1. **Память**: Использование TTL для временных данных
2. **Производительность**: Pipeline операции, connection pooling
3. **Консистентность**: Транзакции для критических операций
4. **Масштабирование**: Шардирование по user_id для сессий
## 11. Безопасность
### Принципы
- TTL для всех временных данных предотвращает накопление мусора
- Раздельное хранение секретных данных (токены) и публичных (кэш)
- Использование pipeline для атомарных операций
- Регулярная очистка истекших ключей
### Рекомендации
- Мониторинг использования памяти Redis
- Backup критичных данных (переменные окружения)
- Ограничение размера значений для предотвращения OOM
- Использование отдельных баз данных для разных типов данных

View File

@ -1,9 +1,7 @@
import asyncio import asyncio
import os import os
from collections.abc import AsyncGenerator
from importlib import import_module from importlib import import_module
from pathlib import Path from pathlib import Path
from typing import Any
from ariadne import load_schema_from_path, make_executable_schema from ariadne import load_schema_from_path, make_executable_schema
from ariadne.asgi import GraphQL from ariadne.asgi import GraphQL
@ -116,7 +114,7 @@ async def shutdown() -> None:
await redis.disconnect() await redis.disconnect()
# Останавливаем поисковый сервис # Останавливаем поисковый сервис
search_service.close() await search_service.close()
# Удаляем PID-файл, если он существует # Удаляем PID-файл, если он существует
from settings import DEV_SERVER_PID_FILE_NAME from settings import DEV_SERVER_PID_FILE_NAME
@ -168,7 +166,7 @@ async def dev_start() -> None:
background_tasks = [] background_tasks = []
async def lifespan(_app: Any) -> AsyncGenerator[None, None]: async def lifespan(app: Starlette):
""" """
Функция жизненного цикла приложения. Функция жизненного цикла приложения.
@ -179,7 +177,7 @@ async def lifespan(_app: Any) -> AsyncGenerator[None, None]:
4. Корректное завершение работы при остановке сервера 4. Корректное завершение работы при остановке сервера
Args: Args:
_app: экземпляр Starlette приложения app: экземпляр Starlette приложения
Yields: Yields:
None: генератор для управления жизненным циклом None: генератор для управления жизненным циклом

View File

@ -88,7 +88,7 @@ async def admin_get_users(
logger.error(f"Ошибка при получении списка пользователей: {e!s}") logger.error(f"Ошибка при получении списка пользователей: {e!s}")
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
msg = f"Не удалось получить список пользователей: {e!s}" msg = f"Не удалось получить список пользователей: {e!s}"
raise GraphQLError(msg) raise GraphQLError(msg) from e
@query.field("adminGetRoles") @query.field("adminGetRoles")
@ -125,12 +125,12 @@ async def admin_get_roles(_: None, info: GraphQLResolveInfo) -> dict[str, Any]:
except Exception as e: except Exception as e:
logger.error(f"Ошибка при получении списка ролей: {e!s}") logger.error(f"Ошибка при получении списка ролей: {e!s}")
msg = f"Не удалось получить список ролей: {e!s}" msg = f"Не удалось получить список ролей: {e!s}"
raise GraphQLError(msg) raise GraphQLError(msg) from e
@query.field("getEnvVariables") @query.field("getEnvVariables")
@admin_auth_required @admin_auth_required
async def get_env_variables(_: None, info: GraphQLResolveInfo) -> dict[str, Any]: async def get_env_variables(_: None, info: GraphQLResolveInfo) -> list[dict[str, Any]]:
""" """
Получает список переменных окружения, сгруппированных по секциям Получает список переменных окружения, сгруппированных по секциям
@ -166,12 +166,12 @@ async def get_env_variables(_: None, info: GraphQLResolveInfo) -> dict[str, Any]
for section in sections for section in sections
] ]
return {"sections": sections_list} return sections_list
except Exception as e: except Exception as e:
logger.error(f"Ошибка при получении переменных окружения: {e!s}") logger.error(f"Ошибка при получении переменных окружения: {e!s}")
msg = f"Не удалось получить переменные окружения: {e!s}" msg = f"Не удалось получить переменные окружения: {e!s}"
raise GraphQLError(msg) raise GraphQLError(msg) from e
@mutation.field("updateEnvVariable") @mutation.field("updateEnvVariable")

View File

@ -4,9 +4,9 @@ import logging
import os import os
import secrets import secrets
import time import time
from typing import Any, Optional from typing import Any, Optional, cast
import httpx from httpx import AsyncClient, Response
# Set up proper logging # Set up proper logging
logger = logging.getLogger("search") logger = logging.getLogger("search")
@ -46,8 +46,8 @@ class SearchCache:
"""Cache for search results to enable efficient pagination""" """Cache for search results to enable efficient pagination"""
def __init__(self, ttl_seconds: int = SEARCH_CACHE_TTL_SECONDS, max_items: int = 100) -> None: def __init__(self, ttl_seconds: int = SEARCH_CACHE_TTL_SECONDS, max_items: int = 100) -> None:
self.cache = {} # Maps search query to list of results self.cache: dict[str, list] = {} # Maps search query to list of results
self.last_accessed = {} # Maps search query to last access timestamp self.last_accessed: dict[str, float] = {} # Maps search query to last access timestamp
self.ttl = ttl_seconds self.ttl = ttl_seconds
self.max_items = max_items self.max_items = max_items
self._redis_prefix = "search_cache:" self._redis_prefix = "search_cache:"
@ -191,8 +191,8 @@ class SearchService:
logger.info(f"Initializing search service with URL: {TXTAI_SERVICE_URL}") logger.info(f"Initializing search service with URL: {TXTAI_SERVICE_URL}")
self.available = SEARCH_ENABLED self.available = SEARCH_ENABLED
# Use different timeout settings for indexing and search requests # Use different timeout settings for indexing and search requests
self.client = httpx.AsyncClient(timeout=30.0, base_url=TXTAI_SERVICE_URL) self.client = AsyncClient(timeout=30.0, base_url=TXTAI_SERVICE_URL)
self.index_client = httpx.AsyncClient(timeout=120.0, base_url=TXTAI_SERVICE_URL) self.index_client = AsyncClient(timeout=120.0, base_url=TXTAI_SERVICE_URL)
# Initialize search cache # Initialize search cache
self.cache = SearchCache() if SEARCH_CACHE_ENABLED else None self.cache = SearchCache() if SEARCH_CACHE_ENABLED else None
@ -208,7 +208,7 @@ class SearchService:
if not self.available: if not self.available:
return {"status": "disabled"} return {"status": "disabled"}
try: try:
response = await self.client.get("/info") response: Response = await self.client.get("/info")
response.raise_for_status() response.raise_for_status()
result = response.json() result = response.json()
logger.info(f"Search service info: {result}") logger.info(f"Search service info: {result}")
@ -228,7 +228,7 @@ class SearchService:
try: try:
logger.info(f"Verifying {len(doc_ids)} documents in search index") logger.info(f"Verifying {len(doc_ids)} documents in search index")
response = await self.client.post( response: Response = await self.client.post(
"/verify-docs", "/verify-docs",
json={"doc_ids": doc_ids}, json={"doc_ids": doc_ids},
timeout=60.0, # Longer timeout for potentially large ID lists timeout=60.0, # Longer timeout for potentially large ID lists
@ -358,10 +358,23 @@ class SearchService:
for i, response in enumerate(responses): for i, response in enumerate(responses):
if isinstance(response, Exception): if isinstance(response, Exception):
logger.error(f"Error in indexing task {i}: {response}") logger.error(f"Error in indexing task {i}: {response}")
elif hasattr(response, "status_code") and response.status_code >= 400: elif hasattr(response, "status_code") and getattr(response, "status_code", 0) >= 400:
logger.error( error_text = ""
f"Error response in indexing task {i}: {response.status_code}, {await response.text()}" if hasattr(response, "text") and isinstance(response.text, str):
) error_text = response.text
elif hasattr(response, "text") and callable(response.text):
try:
# Получаем текст ответа, учитывая разные реализации Response
http_response = cast(Response, response)
# В некоторых версиях httpx, text - это свойство, а не метод
if callable(http_response.text):
error_text = await http_response.text()
else:
error_text = str(http_response.text)
except Exception as e:
error_text = f"[unable to get response text: {e}]"
logger.error(f"Error response in indexing task {i}: {response.status_code}, {error_text}")
logger.info(f"Document {shout.id} indexed across {len(indexing_tasks)} endpoints") logger.info(f"Document {shout.id} indexed across {len(indexing_tasks)} endpoints")
else: else:
@ -556,7 +569,7 @@ class SearchService:
while not success and retry_count < max_retries: while not success and retry_count < max_retries:
try: try:
response = await self.index_client.post(endpoint, json=batch, timeout=90.0) response: Response = await self.index_client.post(endpoint, json=batch, timeout=90.0)
if response.status_code == 422: if response.status_code == 422:
error_detail = response.json() error_detail = response.json()
@ -591,7 +604,7 @@ class SearchService:
) )
break break
wait_time = (2**retry_count) + (secrets.random() * 0.5) wait_time = (2**retry_count) + (secrets.randbelow(500) / 1000)
await asyncio.sleep(wait_time) await asyncio.sleep(wait_time)
def _truncate_error_detail(self, error_detail: Any) -> Any: def _truncate_error_detail(self, error_detail: Any) -> Any:
@ -634,7 +647,7 @@ class SearchService:
return [] return []
# Check if we can serve from cache # Check if we can serve from cache
if SEARCH_CACHE_ENABLED: if SEARCH_CACHE_ENABLED and self.cache is not None:
has_cache = await self.cache.has_query(text) has_cache = await self.cache.has_query(text)
if has_cache: if has_cache:
cached_results = await self.cache.get(text, limit, offset) cached_results = await self.cache.get(text, limit, offset)
@ -648,7 +661,7 @@ class SearchService:
logger.info(f"Searching for: '{text}' (limit={limit}, offset={offset}, search_limit={search_limit})") logger.info(f"Searching for: '{text}' (limit={limit}, offset={offset}, search_limit={search_limit})")
response = await self.client.post( response: Response = await self.client.post(
"/search-combined", "/search-combined",
json={"text": text, "limit": search_limit}, json={"text": text, "limit": search_limit},
) )
@ -664,10 +677,10 @@ class SearchService:
if len(valid_results) != len(formatted_results): if len(valid_results) != len(formatted_results):
formatted_results = valid_results formatted_results = valid_results
if SEARCH_CACHE_ENABLED: if SEARCH_CACHE_ENABLED and self.cache is not None:
# Store the full prefetch batch, then page it # Store the full prefetch batch, then page it
await self.cache.store(text, formatted_results) await self.cache.store(text, formatted_results)
return await self.cache.get(text, limit, offset) return await self.cache.get(text, limit, offset) or []
return formatted_results return formatted_results
except Exception: except Exception:
@ -682,7 +695,7 @@ class SearchService:
cache_key = f"author:{text}" cache_key = f"author:{text}"
# Check if we can serve from cache # Check if we can serve from cache
if SEARCH_CACHE_ENABLED: if SEARCH_CACHE_ENABLED and self.cache is not None:
has_cache = await self.cache.has_query(cache_key) has_cache = await self.cache.has_query(cache_key)
if has_cache: if has_cache:
cached_results = await self.cache.get(cache_key, limit, offset) cached_results = await self.cache.get(cache_key, limit, offset)
@ -696,7 +709,7 @@ class SearchService:
logger.info( logger.info(
f"Searching authors for: '{text}' (limit={limit}, offset={offset}, search_limit={search_limit})" f"Searching authors for: '{text}' (limit={limit}, offset={offset}, search_limit={search_limit})"
) )
response = await self.client.post("/search-author", json={"text": text, "limit": search_limit}) response: Response = await self.client.post("/search-author", json={"text": text, "limit": search_limit})
response.raise_for_status() response.raise_for_status()
result = response.json() result = response.json()
@ -707,10 +720,10 @@ class SearchService:
if len(valid_results) != len(author_results): if len(valid_results) != len(author_results):
author_results = valid_results author_results = valid_results
if SEARCH_CACHE_ENABLED: if SEARCH_CACHE_ENABLED and self.cache is not None:
# Store the full prefetch batch, then page it # Store the full prefetch batch, then page it
await self.cache.store(cache_key, author_results) await self.cache.store(cache_key, author_results)
return await self.cache.get(cache_key, limit, offset) return await self.cache.get(cache_key, limit, offset) or []
return author_results[offset : offset + limit] return author_results[offset : offset + limit]
@ -724,7 +737,7 @@ class SearchService:
return {"status": "disabled"} return {"status": "disabled"}
try: try:
response = await self.client.get("/index-status") response: Response = await self.client.get("/index-status")
response.raise_for_status() response.raise_for_status()
result = response.json() result = response.json()
@ -738,6 +751,14 @@ class SearchService:
logger.exception("Failed to check index status") logger.exception("Failed to check index status")
return {"status": "error", "message": "Failed to check index status"} return {"status": "error", "message": "Failed to check index status"}
async def close(self) -> None:
"""Close connections and release resources"""
if hasattr(self, "client") and self.client:
await self.client.aclose()
if hasattr(self, "index_client") and self.index_client:
await self.index_client.aclose()
logger.info("Search service closed")
# Create the search service singleton # Create the search service singleton
search_service = SearchService() search_service = SearchService()
@ -764,7 +785,7 @@ async def get_search_count(text: str) -> int:
if not search_service.available: if not search_service.available:
return 0 return 0
if SEARCH_CACHE_ENABLED and await search_service.cache.has_query(text): if SEARCH_CACHE_ENABLED and search_service.cache is not None and await search_service.cache.has_query(text):
return await search_service.cache.get_total_count(text) return await search_service.cache.get_total_count(text)
# If not found in cache, fetch from endpoint # If not found in cache, fetch from endpoint
@ -776,9 +797,8 @@ async def get_author_search_count(text: str) -> int:
if not search_service.available: if not search_service.available:
return 0 return 0
if SEARCH_CACHE_ENABLED:
cache_key = f"author:{text}" cache_key = f"author:{text}"
if await search_service.cache.has_query(cache_key): if SEARCH_CACHE_ENABLED and search_service.cache is not None and await search_service.cache.has_query(cache_key):
return await search_service.cache.get_total_count(cache_key) return await search_service.cache.get_total_count(cache_key)
# If not found in cache, fetch from endpoint # If not found in cache, fetch from endpoint