From 72b32d790ff4419fb235449fc45e7a1c4ded027c Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Tue, 15 Sep 2026 18:21:05 +0300 Subject: [PATCH] test --- .gitignore | 3 +- api/LOCAL_MINIO.md | 58 +++++++++++++++ api/run_minio.py | 33 +++++++++ api/src/app.py | 121 +++++++++++++------------------ api/src/core/config.py | 10 ++- api/src/services/file_service.py | 7 +- 6 files changed, 154 insertions(+), 78 deletions(-) create mode 100644 api/LOCAL_MINIO.md create mode 100644 api/run_minio.py diff --git a/.gitignore b/.gitignore index cf9e7ba..143abe8 100644 --- a/.gitignore +++ b/.gitignore @@ -321,6 +321,7 @@ dist .pnp.* # Custom ignore +.local/ .DS_Store Dockerfile docker-compose.yml @@ -338,4 +339,4 @@ graphify-out/ /AGENTS.md /uv.lock -/pyproject.toml \ No newline at end of file +/pyproject.toml diff --git a/api/LOCAL_MINIO.md b/api/LOCAL_MINIO.md new file mode 100644 index 0000000..96de2ba --- /dev/null +++ b/api/LOCAL_MINIO.md @@ -0,0 +1,58 @@ +# MinIO на Mac (Apple Silicon) + +MinIO запускается отдельным процессом, данные сохраняются в `.local/minio/data` +в корне проекта. Папка `.local/` и `api/.env` исключены из Git. + +## Запуск + +Из папки `api`, в активированном окружении: + +```bash +python run_minio.py +``` + +Остановка — Ctrl+C. Данные сохраняются после перезапуска. + +При первоначальной настройке сервер уже запущен в фоне. Его журнал находится +в `.local/minio/server.log`, PID — в `.local/minio/minio.pid` (пути от корня +проекта). Не запускай второй экземпляр на тех же портах. Фоновый процесс не +настроен на автоматический запуск после перезагрузки Mac. + +- S3 API: http://127.0.0.1:9000 +- Консоль: http://127.0.0.1:9001 +- Бакет: `dkb-files` +- Логин и пароль: `OPENBAO__MINIO__MINIO_ADMIN_LOGIN` и + `OPENBAO__MINIO__MINIO_ADMIN_PASSWORD` в `api/.env`. + +Названия переменных окружения регистрозависимы; используй написание из примеров. + +MinIO слушает только локальный интерфейс. Для локального HTTP в `.env` задано: + +```dotenv +OPENBAO__MINIO__MINIO_URL=127.0.0.1:9000 +OPENBAO__MINIO__MINIO_BUCKET=dkb-files +OPENBAO__MINIO__MINIO_SECURE=false +``` + +Адрес указывается без `http://`. Для TLS используется `MINIO_SECURE=true` +(значение по умолчанию). + +После запуска MinIO запусти API в другом терминале: + +```bash +uvicorn src.app:app --reload +``` + +MinIO работает независимо от NATS и SMB. Загруженные файлы сохраняются, но +задания распознавания остаются в очереди до настройки остальных интеграций. + +## Повторная сборка бинарника + +Из корня проекта, после установки Go (`brew install go`): + +```bash +mkdir -p .local/minio/bin +GOBIN="$PWD/.local/minio/bin" go install github.com/minio/minio@RELEASE.2025-10-15T17-29-55Z +``` + +Исходники: https://github.com/minio/minio diff --git a/api/run_minio.py b/api/run_minio.py new file mode 100644 index 0000000..2e23896 --- /dev/null +++ b/api/run_minio.py @@ -0,0 +1,33 @@ +"""Run local MinIO on macOS: python run_minio.py (Ctrl+C to stop).""" + +import os +from pathlib import Path + +from dotenv import dotenv_values + + +def main() -> None: + api_dir = Path(__file__).resolve().parent + local_dir = api_dir.parent / ".local" / "minio" + binary = local_dir / "bin" / "minio" + if not binary.is_file(): + raise SystemExit("MinIO не собран. См. LOCAL_MINIO.md") + values = {**dotenv_values(api_dir / ".env"), **os.environ} + prefix = "OPENBAO__MINIO__" + credentials = [prefix + "MINIO_ADMIN_LOGIN", prefix + "MINIO_ADMIN_PASSWORD"] + if any(not values.get(key) for key in credentials): + raise SystemExit("Укажи логин и пароль MinIO в api/.env") + data_dir = local_dir / "data" + data_dir.mkdir(parents=True, exist_ok=True) + env = dict(os.environ) + env.update( + MINIO_ROOT_USER=values[credentials[0]], + MINIO_ROOT_PASSWORD=values[credentials[1]], + ) + os.execve(binary, [str(binary), "server", str(data_dir), + "--address", "127.0.0.1:9000", + "--console-address", "127.0.0.1:9001"], env) + + +if __name__ == "__main__": + main() diff --git a/api/src/app.py b/api/src/app.py index 4f1edbb..512661a 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -42,9 +42,6 @@ VERSION = "1.0.0" UP_TIME = datetime.now(timezone.utc) -kp = nkeys.from_seed(settings.OPENBAO.NATS.NATS_SEED.encode('utf-8')) - - def jwt_cb(): return settings.OPENBAO.NATS.NATS_JWT.encode('utf-8') @@ -52,6 +49,7 @@ def jwt_cb(): def sig_cb(nonce): if (isinstance(nonce, str)): nonce = nonce.encode('utf-8') + kp = nkeys.from_seed(settings.OPENBAO.NATS.NATS_SEED.encode('utf-8')) signed = kp.sign(nonce) return base64.b64encode(signed) @@ -94,80 +92,61 @@ async def lifespan(app): app.state.nats_subs = [] app.state.storage = None app.state.nats = None + app.state.poller = None + app.state.pdf_poller = None + s3_client = None + smb_registered = False try: - smbclient.register_session(settings.OPENBAO.SMB.SMB_HOST, settings.OPENBAO.SMB.SMB_LOGIN, settings.OPENBAO.SMB.SMB_PASSWORD) - except: - logger.exception("Ошибка при регистрации сессии SMB") - s3_client = Minio(settings.OPENBAO.MINIO.MINIO_URL, access_key=settings.OPENBAO.MINIO.MINIO_ADMIN_LOGIN, secret_key=settings.OPENBAO.MINIO.MINIO_ADMIN_PASSWORD, cert_check=False) - try: - bucket_exists = await s3_client.bucket_exists(settings.OPENBAO.MINIO.MINIO_BUCKET) - logger.info("Бакет проверен. bucket_exists={bucket_exists}", bucket_exists=bucket_exists) - except Exception as exc: - logger.exception( - "Не удалось проверить наличие бакетa. error_category=s3_error, error_type={error_type}, error={error}", - error_type=type(exc).__name__, - error=repr(exc), - ) - return - storage = S3Storage(client=s3_client, bucket_name=settings.OPENBAO.MINIO.MINIO_BUCKET) - app.state.storage = storage - cache = AsyncInMemoryCache() - nc = NATS() - if not settings.DEBUG: - try: - await nc.connect(settings.OPENBAO.NATS.NATS_URL, user_jwt_cb=jwt_cb, signature_cb=sig_cb, - max_reconnect_attempts=2) - logger.info("Подключение к NATS произошло") - except Exception as exc: - logger.exception( - "Не удалось проверить наличие бакетa. error_category=nats_error, error_type={error_type}, error={error}", - error_type=type(exc).__name__, - error=repr(exc), + minio = settings.OPENBAO.MINIO + if minio is not None: + s3_client = Minio( + minio.MINIO_URL, + access_key=minio.MINIO_ADMIN_LOGIN, + secret_key=minio.MINIO_ADMIN_PASSWORD, + secure=minio.MINIO_SECURE, ) - return - poller = PollWorker(nc, cache, storage) - await poller.start() - pdf_poller = PDFPollWorker(cache, storage) - await pdf_poller.start() - app.state.nats = nc - app.state.poller = poller - app.state.pdf_poller = pdf_poller - if not settings.DEBUG: - handler_cb = build_file_handler(nc, storage) + if not await s3_client.bucket_exists(minio.MINIO_BUCKET): + raise RuntimeError(f"Бакет MinIO {minio.MINIO_BUCKET!r} не создан") + app.state.storage = S3Storage(s3_client, minio.MINIO_BUCKET) + logger.info("MinIO подключён, бакет: {}", minio.MINIO_BUCKET) + + missing = [name for name in ("MINIO", "NATS", "SMB") + if getattr(settings.OPENBAO, name) is None] + if missing: + logger.info("Распознавание файлов отключено: не настроены {}", ", ".join(missing)) + else: + smb = settings.OPENBAO.SMB + smbclient.register_session(smb.SMB_HOST, smb.SMB_LOGIN, smb.SMB_PASSWORD) + smb_registered = True + nc = NATS() + app.state.nats = nc + if not settings.DEBUG: + await nc.connect( + settings.OPENBAO.NATS.NATS_URL, + user_jwt_cb=jwt_cb, signature_cb=sig_cb, + max_reconnect_attempts=2, + ) + cache = AsyncInMemoryCache() + app.state.poller = PollWorker(nc, cache, app.state.storage) + await app.state.poller.start() + app.state.pdf_poller = PDFPollWorker(cache, app.state.storage) + await app.state.pdf_poller.start() - try: - # sub_xls = await nc.subscribe('files.tabular_parsed', cb=handler_cb) - # sub_img = await nc.subscribe('files.img_parsed', cb=handler_cb) - # - # app.state.nats_subs = [sub_xls, sub_img] - logger.info("Подписка на топики завершена") - except Exception as exc: - logger.warning( - "Не удалось подписаться на тему. error_category=nats_error, error_type={error_type}, error={error}", - error_type=type(exc).__name__, - error=repr(exc), - ) - try: logger.info("Приложение запущено") yield - except Exception as exc: - logger.exception("ОШИБКА ПРИ РАБОТЕ С СЕКРЕТАМИ error={error}", error=repr(exc)) finally: + for worker in (app.state.poller, app.state.pdf_poller): + if worker is not None: + await worker.stop() + for sub in app.state.nats_subs: + await sub.unsubscribe() + if app.state.nats is not None: + await app.state.nats.close() + if s3_client is not None: + await s3_client.close_session() + if smb_registered: + smbclient.reset_connection_cache() logger.info("Приложение закрывается") - smbclient.reset_connection_cache() - if not settings.DEBUG: - for sub in app.state.nats_subs: - logger.info("Отписываемся от топика") - await sub.unsubscribe() - if app.state.nats: - await app.state.nats.close() - if app.state.poller: - await app.state.poller.stop() - if app.state.storage: - await app.state.storage.client.close_session() - if app.state.pdf_poller: - await app.state.pdf_poller.stop() - logger.info("CLOSING APP AT ALL") app = FastAPI( title="Fast API", @@ -247,4 +226,4 @@ class SPAStaticFiles(StaticFiles): app.mount("/", SPAStaticFiles(directory="web", html=True), name="web") -app.mount("/", StaticFiles(directory="web", html=True), name="web") \ No newline at end of file +app.mount("/", StaticFiles(directory="web", html=True), name="web") diff --git a/api/src/core/config.py b/api/src/core/config.py index d57c41f..1336ccc 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -9,6 +9,7 @@ class MinioSettings(BaseSettings): MINIO_ADMIN_LOGIN: str MINIO_ADMIN_PASSWORD: str MINIO_BUCKET: str + MINIO_SECURE: bool = True class NATSSettings(BaseSettings): NATS_URL: str @@ -23,9 +24,9 @@ class SMBSettings(BaseSettings): class OpenBAOSettings(BaseSettings): DATABASE: DBSettings - MINIO: MinioSettings - NATS: NATSSettings - SMB: SMBSettings + MINIO: MinioSettings | None = None + NATS: NATSSettings | None = None + SMB: SMBSettings | None = None class ServerSettings(BaseSettings): HOST: str = "0.0.0.0" @@ -39,6 +40,7 @@ class Settings(BaseSettings): VERSION: str = '1.0.0' class Config: + case_sensitive = True extra = "ignore" env_file = ".env" env_nested_delimiter = "__" @@ -47,4 +49,4 @@ class Settings(BaseSettings): def get_settings() -> Settings: return Settings() -settings = get_settings() \ No newline at end of file +settings = get_settings() diff --git a/api/src/services/file_service.py b/api/src/services/file_service.py index e5674fd..2dc13b0 100644 --- a/api/src/services/file_service.py +++ b/api/src/services/file_service.py @@ -1,7 +1,7 @@ import json import uuid -from fastapi import UploadFile +from fastapi import HTTPException, UploadFile from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.inspection import inspect from loguru import logger @@ -21,6 +21,8 @@ class FileService: self.session = session async def save_file(self, file: UploadFile, legal_entity_id: uuid.UUID, quarter: str, year: int): + if self.storage is None: + raise HTTPException(status_code=503, detail="Обработка файлов не настроена") try: db_payload = { 'original_name': file.filename, @@ -70,6 +72,8 @@ class FileService: return [self._get_transformed_file_job_info(new_file) for new_file in result] async def get_file_bytes(self, file_id): + if self.storage is None: + raise HTTPException(status_code=503, detail="Файловое хранилище не настроено") result = await self.file_repo.get_by_id(file_id) return (await self.storage.get_object(result.s3_key)) @@ -82,4 +86,3 @@ class FileService: 'original_name': stored_file_db_obj.original_name, 'document_type': stored_file_db_obj.document_type } -