This commit is contained in:
PotapovaA 2026-09-15 18:21:05 +03:00
parent 7decc3378e
commit 72b32d790f
6 changed files with 154 additions and 78 deletions

1
.gitignore vendored
View File

@ -321,6 +321,7 @@ dist
.pnp.* .pnp.*
# Custom ignore # Custom ignore
.local/
.DS_Store .DS_Store
Dockerfile Dockerfile
docker-compose.yml docker-compose.yml

58
api/LOCAL_MINIO.md Normal file
View File

@ -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

33
api/run_minio.py Normal file
View File

@ -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()

View File

@ -42,9 +42,6 @@ VERSION = "1.0.0"
UP_TIME = datetime.now(timezone.utc) UP_TIME = datetime.now(timezone.utc)
kp = nkeys.from_seed(settings.OPENBAO.NATS.NATS_SEED.encode('utf-8'))
def jwt_cb(): def jwt_cb():
return settings.OPENBAO.NATS.NATS_JWT.encode('utf-8') return settings.OPENBAO.NATS.NATS_JWT.encode('utf-8')
@ -52,6 +49,7 @@ def jwt_cb():
def sig_cb(nonce): def sig_cb(nonce):
if (isinstance(nonce, str)): if (isinstance(nonce, str)):
nonce = nonce.encode('utf-8') nonce = nonce.encode('utf-8')
kp = nkeys.from_seed(settings.OPENBAO.NATS.NATS_SEED.encode('utf-8'))
signed = kp.sign(nonce) signed = kp.sign(nonce)
return base64.b64encode(signed) return base64.b64encode(signed)
@ -94,80 +92,61 @@ async def lifespan(app):
app.state.nats_subs = [] app.state.nats_subs = []
app.state.storage = None app.state.storage = None
app.state.nats = None app.state.nats = None
app.state.poller = None
app.state.pdf_poller = None
s3_client = None
smb_registered = False
try: try:
smbclient.register_session(settings.OPENBAO.SMB.SMB_HOST, settings.OPENBAO.SMB.SMB_LOGIN, settings.OPENBAO.SMB.SMB_PASSWORD) minio = settings.OPENBAO.MINIO
except: if minio is not None:
logger.exception("Ошибка при регистрации сессии SMB") s3_client = Minio(
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) minio.MINIO_URL,
try: access_key=minio.MINIO_ADMIN_LOGIN,
bucket_exists = await s3_client.bucket_exists(settings.OPENBAO.MINIO.MINIO_BUCKET) secret_key=minio.MINIO_ADMIN_PASSWORD,
logger.info("Бакет проверен. bucket_exists={bucket_exists}", bucket_exists=bucket_exists) secure=minio.MINIO_SECURE,
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 if not await s3_client.bucket_exists(minio.MINIO_BUCKET):
storage = S3Storage(client=s3_client, bucket_name=settings.OPENBAO.MINIO.MINIO_BUCKET) raise RuntimeError(f"Бакет MinIO {minio.MINIO_BUCKET!r} не создан")
app.state.storage = storage app.state.storage = S3Storage(s3_client, minio.MINIO_BUCKET)
cache = AsyncInMemoryCache() logger.info("MinIO подключён, бакет: {}", minio.MINIO_BUCKET)
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),
)
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)
try: missing = [name for name in ("MINIO", "NATS", "SMB")
# sub_xls = await nc.subscribe('files.tabular_parsed', cb=handler_cb) if getattr(settings.OPENBAO, name) is None]
# sub_img = await nc.subscribe('files.img_parsed', cb=handler_cb) if missing:
# logger.info("Распознавание файлов отключено: не настроены {}", ", ".join(missing))
# app.state.nats_subs = [sub_xls, sub_img] else:
logger.info("Подписка на топики завершена") smb = settings.OPENBAO.SMB
except Exception as exc: smbclient.register_session(smb.SMB_HOST, smb.SMB_LOGIN, smb.SMB_PASSWORD)
logger.warning( smb_registered = True
"Не удалось подписаться на тему. error_category=nats_error, error_type={error_type}, error={error}", nc = NATS()
error_type=type(exc).__name__, app.state.nats = nc
error=repr(exc), 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,
) )
try: 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()
logger.info("Приложение запущено") logger.info("Приложение запущено")
yield yield
except Exception as exc:
logger.exception("ОШИБКА ПРИ РАБОТЕ С СЕКРЕТАМИ error={error}", error=repr(exc))
finally: finally:
logger.info("Приложение закрывается") for worker in (app.state.poller, app.state.pdf_poller):
smbclient.reset_connection_cache() if worker is not None:
if not settings.DEBUG: await worker.stop()
for sub in app.state.nats_subs: for sub in app.state.nats_subs:
logger.info("Отписываемся от топика")
await sub.unsubscribe() await sub.unsubscribe()
if app.state.nats: if app.state.nats is not None:
await app.state.nats.close() await app.state.nats.close()
if app.state.poller: if s3_client is not None:
await app.state.poller.stop() await s3_client.close_session()
if app.state.storage: if smb_registered:
await app.state.storage.client.close_session() smbclient.reset_connection_cache()
if app.state.pdf_poller: logger.info("Приложение закрывается")
await app.state.pdf_poller.stop()
logger.info("CLOSING APP AT ALL")
app = FastAPI( app = FastAPI(
title="Fast API", title="Fast API",

View File

@ -9,6 +9,7 @@ class MinioSettings(BaseSettings):
MINIO_ADMIN_LOGIN: str MINIO_ADMIN_LOGIN: str
MINIO_ADMIN_PASSWORD: str MINIO_ADMIN_PASSWORD: str
MINIO_BUCKET: str MINIO_BUCKET: str
MINIO_SECURE: bool = True
class NATSSettings(BaseSettings): class NATSSettings(BaseSettings):
NATS_URL: str NATS_URL: str
@ -23,9 +24,9 @@ class SMBSettings(BaseSettings):
class OpenBAOSettings(BaseSettings): class OpenBAOSettings(BaseSettings):
DATABASE: DBSettings DATABASE: DBSettings
MINIO: MinioSettings MINIO: MinioSettings | None = None
NATS: NATSSettings NATS: NATSSettings | None = None
SMB: SMBSettings SMB: SMBSettings | None = None
class ServerSettings(BaseSettings): class ServerSettings(BaseSettings):
HOST: str = "0.0.0.0" HOST: str = "0.0.0.0"
@ -39,6 +40,7 @@ class Settings(BaseSettings):
VERSION: str = '1.0.0' VERSION: str = '1.0.0'
class Config: class Config:
case_sensitive = True
extra = "ignore" extra = "ignore"
env_file = ".env" env_file = ".env"
env_nested_delimiter = "__" env_nested_delimiter = "__"

View File

@ -1,7 +1,7 @@
import json import json
import uuid import uuid
from fastapi import UploadFile from fastapi import HTTPException, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.inspection import inspect from sqlalchemy.inspection import inspect
from loguru import logger from loguru import logger
@ -21,6 +21,8 @@ class FileService:
self.session = session self.session = session
async def save_file(self, file: UploadFile, legal_entity_id: uuid.UUID, quarter: str, year: int): 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: try:
db_payload = { db_payload = {
'original_name': file.filename, 'original_name': file.filename,
@ -70,6 +72,8 @@ class FileService:
return [self._get_transformed_file_job_info(new_file) for new_file in result] return [self._get_transformed_file_job_info(new_file) for new_file in result]
async def get_file_bytes(self, file_id): 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) result = await self.file_repo.get_by_id(file_id)
return (await self.storage.get_object(result.s3_key)) return (await self.storage.get_object(result.s3_key))
@ -82,4 +86,3 @@ class FileService:
'original_name': stored_file_db_obj.original_name, 'original_name': stored_file_db_obj.original_name,
'document_type': stored_file_db_obj.document_type 'document_type': stored_file_db_obj.document_type
} }