исправление обработки 404

This commit is contained in:
2026-02-04 23:21:53 +03:00
parent a336d50ad0
commit 8ad70cdb7c
21 changed files with 173 additions and 201 deletions
+1 -18
View File
@@ -11,7 +11,7 @@ services:
- ./data/db:/var/lib/postgresql/data
networks:
- proxy
ports: # !сменить внешний порт перед использованием!
ports: # !только локальный тест!
- 5432:5432
env_file:
- ./.env
@@ -31,23 +31,6 @@ services:
timeout: 5s
retries: 5
replication-setup:
image: postgres:17-alpine
container_name: replication-setup
restart: "no"
networks:
- proxy
env_file:
- ./.env
volumes:
- ./setup-replication.sh:/setup-replication.sh
entrypoint: ["/bin/sh", "/setup-replication.sh"]
depends_on:
api:
condition: service_started
db:
condition: service_healthy
llm:
image: ollama/ollama:latest
container_name: llm
-3
View File
@@ -4,9 +4,6 @@ POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=lib
REMOTE_HOST=
REMOTE_PORT=
NODE_ID=
# Ollama
OLLAMA_URL="http://llm:11434"
-4
View File
@@ -1,4 +0,0 @@
CREATE PUBLICATION all_tables_pub FOR ALL TABLES;
ALTER SYSTEM SET password_encryption = 'scram-sha-256';
SELECT pg_reload_conf();
+7 -2
View File
@@ -73,16 +73,21 @@ app = get_app(lifespan)
@app.exception_handler(status.HTTP_404_NOT_FOUND)
async def custom_not_found_handler(request: Request, exc: HTTPException):
if exc.detail == "Not Found":
path = request.url.path
if path.startswith("/api"):
if path.startswith("/api/"):
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,
content={"detail": "API endpoint not found", "path": path},
)
return await unknown(request, app)
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
)
@app.middleware("http")
async def catch_exceptions_middleware(request: Request, call_next):
+4 -2
View File
@@ -287,7 +287,8 @@ def enable_2fa(
if not current_user.totp_secret:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Secret key not generated"
status_code=status.HTTP_400_BAD_REQUEST,
detail="Secret key not generated",
)
if not verify_totp_code(secret, data.code):
@@ -299,7 +300,8 @@ def enable_2fa(
decrypted = cipher.decrypt(base64.b64decode(current_user.totp_secret.encode()))
if secret != decrypted.decode():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Incorret secret"
status_code=status.HTTP_400_BAD_REQUEST,
detail="Incorret secret",
)
current_user.is_2fa_enabled = True
+6 -3
View File
@@ -67,7 +67,8 @@ def get_author(
author = session.get(Author, author_id)
if not author:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Author not found"
status_code=status.HTTP_404_NOT_FOUND,
detail="Author not found",
)
books = session.exec(
@@ -98,7 +99,8 @@ def update_author(
db_author = session.get(Author, author_id)
if not db_author:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Author not found"
status_code=status.HTTP_404_NOT_FOUND,
detail="Author not found",
)
update_data = author.model_dump(exclude_unset=True)
@@ -125,7 +127,8 @@ def delete_author(
author = session.get(Author, author_id)
if not author:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Author not found"
status_code=status.HTTP_404_NOT_FOUND,
detail="Author not found",
)
author_read = AuthorRead(**author.model_dump())
+34 -18
View File
@@ -1,23 +1,20 @@
"""Модуль работы с книгами"""
from library_service.services import transcode_image
import shutil
from uuid import uuid4
from pydantic import Field
from typing_extensions import Annotated
from sqlalchemy.orm import selectinload, defer
from sqlalchemy import text, case, distinct
from uuid import uuid4
import shutil
from datetime import datetime, timezone
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status, UploadFile, File
from ollama import Client
from pydantic import Field
from sqlalchemy import text, case, distinct
from sqlalchemy.orm import selectinload, defer
from sqlmodel import Session, select, col, func
from library_service.auth import RequireStaff
from library_service.services import transcode_image
from library_service.settings import get_session, OLLAMA_URL, BOOKS_PREVIEW_DIR
from library_service.models.enums import BookStatus
from library_service.models.db import (
@@ -139,7 +136,8 @@ def create_book(
session.commit()
session.refresh(db_book)
book_data = db_book.model_dump(exclude={"embedding", "preview_id"})
book_dict = {k: v for k, v in db_book.__dict__.items() if not k.startswith('_')}
book_data = {k: v for k, v in book_dict.items() if k not in {"embedding", "preview_id"}}
book_data["preview_urls"] = {
"png": f"/static/books/{db_book.preview_id}.png",
"jpeg": f"/static/books/{db_book.preview_id}.jpg",
@@ -160,12 +158,17 @@ def read_books(session: Session = Depends(get_session)):
books_data = []
for book in books:
book_data = book.model_dump(exclude={"embedding", "preview_id"})
book = book[0]
book_dict = dict(book)
book_data = {k: v for k, v in book_dict.items() if k not in {"embedding", "preview_id"}}
book_data["preview_urls"] = {
"png": f"/static/books/{book.preview_id}.png",
"jpeg": f"/static/books/{book.preview_id}.jpg",
"webp": f"/static/books/{book.preview_id}.webp",
} if book.preview_id else {}
books_data.append(book_data)
return BookList(
@@ -188,7 +191,8 @@ def get_book(
book = session.get(Book, book_id)
if not book:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
status_code=status.HTTP_404_NOT_FOUND,
detail="Book not found",
)
authors = session.scalars(
@@ -203,7 +207,8 @@ def get_book(
genre_reads = [GenreRead(**genre.model_dump()) for genre in genres]
book_data = book.model_dump(exclude={"embedding", "preview_id"})
book_dict = {k: v for k, v in book.__dict__.items() if not k.startswith('_')}
book_data = {k: v for k, v in book_dict.items() if k not in {"embedding", "preview_id"}}
book_data["preview_urls"] = {
"png": f"/static/books/{book.preview_id}.png",
"jpeg": f"/static/books/{book.preview_id}.jpg",
@@ -231,7 +236,8 @@ def update_book(
db_book = session.get(Book, book_id)
if not db_book:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
status_code=status.HTTP_404_NOT_FOUND,
detail="Book not found",
)
if book_update.status is not None:
@@ -292,7 +298,8 @@ def delete_book(
book = session.get(Book, book_id)
if not book:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
status_code=status.HTTP_404_NOT_FOUND,
detail="Book not found",
)
book_read = BookRead(
id=(book.id or 0),
@@ -313,10 +320,16 @@ async def upload_book_preview(
session: Session = Depends(get_session)
):
if not (file.content_type or "").startswith("image/"):
raise HTTPException(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "Image required")
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail="Image required",
)
if (file.size or 0) > 32 * 1024 * 1024:
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "File larger than 10 MB")
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="File larger than 10 MB",
)
file_uuid= uuid4()
tmp_path = BOOKS_PREVIEW_DIR / f"{file_uuid}.upload"
@@ -327,7 +340,10 @@ async def upload_book_preview(
book = session.get(Book, book_id)
if not book:
tmp_path.unlink()
raise HTTPException(status.HTTP_404_NOT_FOUND, "Book not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Book not found",
)
transcode_image(tmp_path)
tmp_path.unlink()
+14 -6
View File
@@ -27,11 +27,13 @@ async def challenge(request: Request, ip: str = Depends(get_ip)):
"""Возвращает задачу capjs"""
if challenges_by_ip[ip] >= MAX_CHALLENGES_PER_IP:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many challenges"
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many challenges",
)
if len(active_challenges) >= MAX_TOTAL_CHALLENGES:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Server busy"
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Server busy",
)
token = secrets.token_hex(25)
@@ -60,17 +62,22 @@ async def redeem(request: Request, payload: dict, ip: str = Depends(get_ip)):
if token not in active_challenges:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Invalid challenge"
status_code=status.HTTP_404_NOT_FOUND,
detail="Invalid challenge",
)
ch = active_challenges.pop(token)
challenges_by_ip[ch["ip"]] -= 1
if now_ms() > ch["expires"]:
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Expired")
raise HTTPException(
status_code=status.HTTP_410_GONE,
detail="Expired"
)
if len(solutions) < ch["c"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Bad solutions"
status_code=status.HTTP_400_BAD_REQUEST,
detail="Bad solutions",
)
def verify(i: int) -> bool:
@@ -84,7 +91,8 @@ async def redeem(request: Request, payload: dict, ip: str = Depends(get_ip)):
)
if not all(results):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid solution"
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid solution",
)
r_token = ch["redeem_token"]
+6 -3
View File
@@ -66,7 +66,8 @@ def get_genre(
genre = session.get(Genre, genre_id)
if not genre:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Genre not found"
status_code=status.HTTP_404_NOT_FOUND,
detail="Genre not found",
)
books = session.exec(
@@ -97,7 +98,8 @@ def update_genre(
db_genre = session.get(Genre, genre_id)
if not db_genre:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Genre not found"
status_code=status.HTTP_404_NOT_FOUND,
detail="Genre not found",
)
update_data = genre.model_dump(exclude_unset=True)
@@ -124,7 +126,8 @@ def delete_genre(
genre = session.get(Genre, genre_id)
if not genre:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Genre not found"
status_code=status.HTTP_404_NOT_FOUND,
detail="Genre not found",
)
genre_read = GenreRead(**genre.model_dump())
+30 -15
View File
@@ -41,7 +41,8 @@ def create_loan(
book = session.get(Book, loan.book_id)
if not book:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
status_code=status.HTTP_404_NOT_FOUND,
detail="Book not found",
)
if book.status != BookStatus.ACTIVE:
@@ -53,7 +54,8 @@ def create_loan(
target_user = session.get(User, loan.user_id)
if not target_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
db_loan = BookUserLink(
@@ -253,14 +255,16 @@ def get_loan(
if not loan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Loan not found"
status_code=status.HTTP_404_NOT_FOUND,
detail="Loan not found",
)
is_staff = is_user_staff(current_user)
if not is_staff and loan.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this loan"
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this loan",
)
return LoanRead(**loan.model_dump())
@@ -282,7 +286,8 @@ def update_loan(
db_loan = session.get(BookUserLink, loan_id)
if not db_loan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Loan not found"
status_code=status.HTTP_404_NOT_FOUND,
detail="Loan not found",
)
is_staff = is_user_staff(current_user)
@@ -296,7 +301,8 @@ def update_loan(
book = session.get(Book, db_loan.book_id)
if not book:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
status_code=status.HTTP_404_NOT_FOUND,
detail="Book not found",
)
if loan_update.user_id is not None:
@@ -308,7 +314,8 @@ def update_loan(
new_user = session.get(User, loan_update.user_id)
if not new_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
db_loan.user_id = loan_update.user_id
@@ -347,18 +354,21 @@ def confirm_loan(
loan = session.get(BookUserLink, loan_id)
if not loan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Loan not found"
status_code=status.HTTP_404_NOT_FOUND,
detail="Loan not found",
)
if loan.returned_at:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Loan is already returned"
status_code=status.HTTP_400_BAD_REQUEST,
detail="Loan is already returned",
)
book = session.get(Book, loan.book_id)
if not book:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
status_code=status.HTTP_404_NOT_FOUND,
detail="Book not found",
)
if book.status not in [BookStatus.RESERVED, BookStatus.ACTIVE]:
@@ -392,12 +402,14 @@ def return_loan(
loan = session.get(BookUserLink, loan_id)
if not loan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Loan not found"
status_code=status.HTTP_404_NOT_FOUND,
detail="Loan not found",
)
if loan.returned_at:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Loan is already returned"
status_code=status.HTTP_400_BAD_REQUEST,
detail="Loan is already returned",
)
loan.returned_at = datetime.now(timezone.utc)
@@ -429,7 +441,8 @@ def delete_loan(
loan = session.get(BookUserLink, loan_id)
if not loan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Loan not found"
status_code=status.HTTP_404_NOT_FOUND,
detail="Loan not found",
)
is_staff = is_user_staff(current_user)
@@ -499,7 +512,8 @@ def issue_book_directly(
book = session.get(Book, loan.book_id)
if not book:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
status_code=status.HTTP_404_NOT_FOUND,
detail="Book not found",
)
if book.status != BookStatus.ACTIVE:
@@ -511,7 +525,8 @@ def issue_book_directly(
target_user = session.get(User, loan.user_id)
if not target_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
db_loan = BookUserLink(
+49 -10
View File
@@ -56,8 +56,14 @@ async def create_genre(request: Request, app=Depends(lambda: get_app())):
@router.get("/genre/{genre_id}/edit", include_in_schema=False)
async def edit_genre(request: Request, genre_id: int, app=Depends(lambda: get_app())):
async def edit_genre(request: Request, genre_id: str, app=Depends(lambda: get_app())):
"""Рендерит страницу редактирования жанра"""
try:
assert int(genre_id) > 0
except:
return await unknown(request, app)
return templates.TemplateResponse(request, "edit_genre.html", get_info(app) | {"request": request, "title": "LiB - Редактировать жанр", "id": genre_id})
@@ -74,15 +80,32 @@ async def create_author(request: Request, app=Depends(lambda: get_app())):
@router.get("/author/{author_id}/edit", include_in_schema=False)
async def edit_author(request: Request, author_id: int, app=Depends(lambda: get_app())):
async def edit_author(request: Request, author_id: str, app=Depends(lambda: get_app()), session=Depends(get_session)):
"""Рендерит страницу редактирования автора"""
return templates.TemplateResponse(request, "edit_author.html", get_info(app) | {"request": request, "title": "LiB - Редактировать автора", "id": author_id})
try:
author = session.get(Author, int(author_id))
assert author is not None
except:
return await unknown(request, app)
return templates.TemplateResponse(request, "edit_author.html", get_info(app) | {"request": request, "title": f"LiB - Редактировать автора \"{author.name}\"", "id": author_id})
@router.get("/author/{author_id}", include_in_schema=False)
async def author(request: Request, author_id: int, app=Depends(lambda: get_app())):
async def author(request: Request, author_id: str, app=Depends(lambda: get_app()), session=Depends(get_session)):
"""Рендерит страницу просмотра автора"""
return templates.TemplateResponse(request, "author.html", get_info(app) | {"request": request, "title": "LiB - Автор", "id": author_id})
if author_id == "":
return RedirectResponse("/authors")
try:
author = session.get(Author, int(author_id))
assert author is not None
except:
return await unknown(request, app)
return templates.TemplateResponse(request, "author.html", get_info(app) | {"request": request, "title": f"LiB - Автор \"{author.name}\"", "id": author_id})
@router.get("/books", include_in_schema=False)
@@ -98,16 +121,32 @@ async def create_book(request: Request, app=Depends(lambda: get_app())):
@router.get("/book/{book_id}/edit", include_in_schema=False)
async def edit_book(request: Request, book_id: int, app=Depends(lambda: get_app())):
async def edit_book(request: Request, book_id: str, app=Depends(lambda: get_app()), session=Depends(get_session)):
"""Рендерит страницу редактирования книги"""
return templates.TemplateResponse(request, "edit_book.html", get_info(app) | {"request": request, "title": "LiB - Редактировать книгу", "id": book_id})
try:
book = session.get(Book, int(book_id))
assert book is not None
except:
return await unknown(request, app)
return templates.TemplateResponse(request, "edit_book.html", get_info(app) | {"request": request, "title": f"LiB - Редактировать книгу \"{book.title}\"", "id": book_id})
@router.get("/book/{book_id}", include_in_schema=False)
async def book(request: Request, book_id: int, app=Depends(lambda: get_app()), session=Depends(get_session)):
async def book(request: Request, book_id: str, app=Depends(lambda: get_app()), session=Depends(get_session)):
"""Рендерит страницу просмотра книги"""
book = session.get(Book, book_id)
return templates.TemplateResponse(request, "book.html", get_info(app) | {"request": request, "title": "LiB - Книга", "id": book_id, "img": book.preview_id})
if book_id == "":
return RedirectResponse("/books")
try:
book = session.get(Book, int(book_id))
assert book is not None
except:
return await unknown(request, app)
return templates.TemplateResponse(request, "book.html", get_info(app) | {"request": request, "title": f"LiB - Книга \"{book.title}\"", "id": book_id, "img": book.preview_id})
@router.get("/auth", include_in_schema=False)
+8 -3
View File
@@ -19,7 +19,8 @@ def check_entity_exists(session, model, entity_id, entity_name):
entity = session.get(model, entity_id)
if not entity:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"{entity_name} not found"
status_code=status.HTTP_404_NOT_FOUND,
detail=f"{entity_name} not found",
)
return entity
@@ -33,7 +34,10 @@ def add_relationship(session, link_model, id1, field1, id2, field2, detail):
).first()
if existing_link:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=detail,
)
link = link_model(**{field1: id1, field2: id2})
session.add(link)
@@ -52,7 +56,8 @@ def remove_relationship(session, link_model, id1, field1, id2, field2):
if not link:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Relationship not found"
status_code=status.HTTP_404_NOT_FOUND,
detail="Relationship not found",
)
session.delete(link)
+2 -1
View File
@@ -72,6 +72,7 @@ async def require_captcha(request: Request):
token = request.cookies.get("capjs_token")
if not token or token not in redeem_tokens or redeem_tokens[token] < now_ms():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail={"error": "captcha_required"}
status_code=status.HTTP_403_FORBIDDEN,
detail={"error": "captcha_required"},
)
del redeem_tokens[token]
-1
View File
@@ -9,7 +9,6 @@ $(document).ready(() => {
Api.get(`/api/authors/${authorId}`)
.then((author) => {
document.title = `LiB - ${author.name}`;
renderAuthor(author);
renderBooks(author.books);
if (window.canManage()) {
-1
View File
@@ -366,7 +366,6 @@ $(document).ready(() => {
Api.get(`/api/books/${bookId}`)
.then((book) => {
currentBook = book;
document.title = `LiB - ${book.title}`;
renderBook(book);
if (window.canManage()) {
$("#edit-book-btn")
@@ -32,7 +32,6 @@ $(document).ready(() => {
originalAuthor = author;
authorBooks = booksData.books || booksData || [];
document.title = `Редактирование: ${author.name} | LiB`;
populateForm(author);
renderAuthorBooks(authorBooks);
-1
View File
@@ -49,7 +49,6 @@ $(document).ready(() => {
currentGenres.set(g.id, g.name),
);
document.title = `Редактирование: ${book.title} | LiB`;
populateForm(book);
initAuthorsDropdown();
initGenresDropdown();
@@ -35,7 +35,6 @@ $(document).ready(() => {
originalGenre = genre;
genreBooks = booksData.books || booksData || [];
document.title = `Редактирование: ${genre.name} | LiB`;
populateForm(genre);
renderGenreBooks(genreBooks);
+1 -1
View File
@@ -17,7 +17,7 @@ $(document).ready(() => {
Api.get("/api/auth/recovery-codes/status").catch(() => null),
])
.then(async ([user, rolesData, recoveryStatus]) => {
document.title = `LiB - ${user.full_name || user.username}`;
document.title = `LiB - Профиль ${user.full_name || user.username}`;
currentUsername = user.username;
await renderProfileHeader(user);
+6 -1
View File
@@ -2,10 +2,15 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<title id="pageTitle">Loading...</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta property="og:title" content="LiB - API Документация" />
<meta property="og:type" content="website" />
<meta property="og:description" content="Ваша персональная библиотека книг" />
<meta property="og:url" content="{{ request.url.scheme }}://{{ domain }}/" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/dagre/0.8.5/dagre.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsPlumb/2.15.6/js/jsplumb.min.js"></script>
<title id="pageTitle">Loading...</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
-101
View File
@@ -1,101 +0,0 @@
#!/bin/sh
set -e
echo "=== Настройка репликации ==="
echo "Этот узел: NODE_ID=${NODE_ID}"
echo "Удаленный хост: ${REMOTE_HOST}"
echo "Ждем локальную базу..."
sleep 10
until PGPASSWORD="${POSTGRES_PASSWORD}" psql -h db -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -c '\q' 2>/dev/null; do
echo "Локальная база не готова, ждем..."
sleep 2
done
echo "Локальная база готова"
echo "Настройка генераторов ID (NODE_ID=${NODE_ID})..."
PGPASSWORD="${POSTGRES_PASSWORD}" psql -h db -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" <<EOF
DO \$\$
DECLARE
r RECORD;
BEGIN
FOR r IN
SELECT table_schema, table_name, column_name
FROM information_schema.columns
WHERE is_identity = 'YES'
AND table_schema = 'public'
LOOP
EXECUTE format(
'ALTER TABLE %I.%I ALTER COLUMN %I SET GENERATED BY DEFAULT AS IDENTITY (START WITH %s INCREMENT BY 2)',
r.table_schema, r.table_name, r.column_name, ${NODE_ID}
);
RAISE NOTICE 'Настроен ID для %.%', r.table_name, r.column_name;
END LOOP;
END \$\$;
EOF
echo "Проверяем/создаем публикацию..."
PUB_EXISTS=$(PGPASSWORD="${POSTGRES_PASSWORD}" psql -h db -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -tAc "SELECT COUNT(*) FROM pg_publication WHERE pubname = 'all_tables_pub';")
if [ "$PUB_EXISTS" -gt 0 ]; then
echo "Публикация уже существует"
else
echo "Создаем публикацию..."
PGPASSWORD="${POSTGRES_PASSWORD}" psql -h db -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" <<EOF
CREATE PUBLICATION all_tables_pub FOR ALL TABLES;
EOF
echo "Публикация создана!"
fi
echo "Ждем удаленный хост ${REMOTE_HOST}:${REMOTE_PORT}..."
TIMEOUT=300
ELAPSED=0
while ! PGPASSWORD="${POSTGRES_PASSWORD}" psql -h "${REMOTE_HOST}" -p ${REMOTE_PORT} -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -c '\q' 2>/dev/null; do
sleep 5
ELAPSED=$((ELAPSED + 5))
if [ $ELAPSED -ge $TIMEOUT ]; then
echo "Таймаут ожидания удаленного хоста. Подписка НЕ настроена."
echo "Публикация создана - удаленный хост сможет подписаться на нас."
echo "Для создания подписки запустите позже:"
echo "docker compose restart replication-setup"
exit 0
fi
echo "Удаленный хост недоступен, ждем... (${ELAPSED}s/${TIMEOUT}s)"
done
echo "Удаленный хост доступен"
REMOTE_PUB=$(PGPASSWORD="${POSTGRES_PASSWORD}" psql -h "${REMOTE_HOST}" -p ${REMOTE_PORT} -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -tAc "SELECT COUNT(*) FROM pg_publication WHERE pubname = 'all_tables_pub';" 2>/dev/null || echo "0")
if [ "$REMOTE_PUB" -eq 0 ]; then
echo "ВНИМАНИЕ: На удалённом хосте нет публикации 'all_tables_pub'!"
echo "Подписка не будет создана. Сначала запустите скрипт на удалённом хосте."
exit 0
fi
EXISTING=$(PGPASSWORD="${POSTGRES_PASSWORD}" psql -h db -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -tAc "SELECT COUNT(*) FROM pg_subscription WHERE subname = 'sub_from_remote';")
if [ "$EXISTING" -gt 0 ]; then
echo "Подписка уже существует, пропускаем создание"
else
echo "Создаем подписку на удаленный хост..."
PGPASSWORD="${POSTGRES_PASSWORD}" psql -h db -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" <<EOF
CREATE SUBSCRIPTION sub_from_remote
CONNECTION 'host=${REMOTE_HOST} port=${REMOTE_PORT} user=${POSTGRES_USER} password=${POSTGRES_PASSWORD} dbname=${POSTGRES_DB}'
PUBLICATION all_tables_pub
WITH (
origin = none
);
EOF
echo "Подписка создана!"
fi
echo ""
echo "=== Репликация настроена! ==="
echo "Публикация: all_tables_pub (другие могут подписаться на нас)"
echo "Подписка: sub_from_remote (мы получаем данные от ${REMOTE_HOST})"