maintainance
This commit is contained in:
parent
0375939e73
commit
8a5f4a2421
14
README.md
14
README.md
|
@ -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
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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] Подготовлен расширенный контекст для запроса")
|
||||||
|
|
||||||
|
|
|
@ -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
8
cache/precache.py
vendored
|
@ -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
33
dev.py
|
@ -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)
|
||||||
|
|
|
@ -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 = "..."
|
||||||
# ... другие провайдеры
|
# ... другие провайдеры
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
@ -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
434
docs/redis-schema.md
Normal 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
|
||||||
|
- Использование отдельных баз данных для разных типов данных
|
8
main.py
8
main.py
|
@ -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: генератор для управления жизненным циклом
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue
Block a user