Проектирование SaaS-сервиса для чатов с ИИ с использованием Next.js, FastAPI, Credits и SSE
Рассмотрим архитектуру чат-системы на основе искусственного интеллекта с использованием полноценного стека: воротник FastAPI, реестр кредитов, ограничения скорости работы через Redis и потоковая передача данных по протоколу SSE, а также те недостатки, которые необходимо устранить в первую очередь.
Источники: Build a Production-Ready AI SaaS with React 19 + FastAPI: The Complete Architecture (2026)
Создание интерфейса чата на основе API модели — дело простое; однако продукт также должен обеспечивать хранение ключей вне клиента, контроль использования ресурсов, ограничение чрезмерной нагрузки, чистую передачу токенов для стриминга и возможность воспроизводимой развертки. В этом руководстве описывается построение всех необходимых компонентов с использованием Next.js и React 19, FastAPI, PostgreSQL с расширением pgvector и Redis. Рассматривайте код как черновик: в каждом разделе указано, что следует усилить в первую очередь.
Бэкенд — главный контролер
Браузер никогда не обращается к поставщику модели. Ваш API осуществляет аутентификацию, проверяет лимиты и остаток кредитов, вызывает модель и фиксирует затраты.
┌─────────────────────────────────────────────────────────────┐
│ React 19 App │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Server │ │ Client │ │ Chat Interface │ │
│ │ Components │ │ Components │ │ (Streaming) │ │
│ │ (Initial │ │ (Interactivity│ │ │ │
│ │ data fetch)│ │ + State) │ │ │ │
│ └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘ │
│ │ │ │ │
│ └─────────────────┴────────────────────┘ │
│ │ │
│ TanStack Query │
│ (Auth + Sync) │
└───────────────────────────┬─────────────────────────────────┘
│ HTTPS / JSON
┌───────────────────────────┴─────────────────────────────────┐
│ FastAPI Backend │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Auth │ │ Rate │ │ OpenAI │ │
│ │ Middleware │ │ Limiter │ │ Service │ │
│ │ (JWT + │ │ (Redis) │ │ (Structured │ │
│ │ Credits) │ │ │ │ Output) │ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Background │ │ PostgreSQL │ │ pgvector │ │
│ │ Worker │ │ (Users + │ │ (Embeddings) │ │
│ │ (Celery) │ │ Conversations│ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Компоненты серверной части загружают историю без дополнительного JavaScript, асинхронный FastAPI обрабатывает медленные запросы к модели без блокировки потоков, pgvector обеспечивает возможность поиска данных, Redis хранит счетчики ограничений скорости и кэшированные эмбеддинги, а специальный воркер берет на себя задачи по парсингу PDF и генерации эмбеддингов, снимая их с основного пути обработки запросов.
Модель данных: запись в журнал
Шаблон определяет пользователей, разговоры, сообщения и учетную книгу кредитов.
# backend/models.py
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, Numeric, Index
from sqlalchemy.dialects.postgresql import UUID, JSONB, VECTOR
from sqlalchemy.orm import relationship
from datetime import datetime
import uuid
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email = Column(String, unique=True, nullable=False)
hashed_password = Column(String, nullable=False)
credits_balance = Column(Numeric(10, 2), default=0.00)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
conversations = relationship("Conversation", back_populates="user")
__table_args__ = (
Index('ix_users_email', 'email'),
)
class Conversation(Base):
__tablename__ = "conversations"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
title = Column(String, nullable=True)
model = Column(String, default="gpt-4o")
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user = relationship("User", back_populates="conversations")
messages = relationship("Message", back_populates="conversation", order_by="Message.created_at")
class Message(Base):
__tablename__ = "messages"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
conversation_id = Column(UUID(as_uuid=True), ForeignKey("conversations.id"), nullable=False)
role = Column(String, nullable=False) # 'user', 'assistant', 'system'
content = Column(Text, nullable=False)
token_count = Column(Integer, default=0)
metadata = Column(JSONB, default={}) # Store model, latency, finish_reason
created_at = Column(DateTime, default=datetime.utcnow)
conversation = relationship("Conversation", back_populates="messages")
class CreditLedger(Base):
__tablename__ = "credit_ledger"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
amount = Column(Numeric(10, 2), nullable=False) # Negative for usage
description = Column(String, nullable=False)
reference_id = Column(String, nullable=True) # conversation_id or message_id
created_at = Column(DateTime, default=datetime.utcnow)
Вместо изменения баланса каждая покупка или операция добавляется отдельно, а баланс составляет сумму всех этих операций, что обеспечивает возможность аудита и предотвращает потерю обновлений. Перед использованием необходимо исправить несколько моментов: Message.metadata перекрывает атрибут, зарезервированный декларативной базой SQLAlchemy, поэтому его следует переименовать (например, в meta); отсутствуют импорты такие как Boolean и declarative_base; тип столбца pgvector взят из пакета pgvector, а не из диалекта PostgreSQL SQLAlchemy.
Службы и маршруты FastAPI
Традиционная структура разделяет механизмы аутентификации, логику ИИ, роутеры и промежуточные компоненты:
backend/
├── main.py
├── config.py
├── database.py
├── models.py
├── schemas.py
├── auth/
│ ├── utils.py
│ ├── dependencies.py
│ └── router.py
├── ai/
│ ├── service.py
│ └── schemas.py
├── api/
│ ├── conversations.py
│ └── credits.py
├── middleware/
│ └── rate_limit.py
└── worker.py
Служба стриминга с измерением объема использования
Этот сервис позволяет каждому пользователю отправлять по 20 запросов в минуту через Redis, проверять баланс, транслировать Server-Sent Events, а затем одновременно сохранять сообщение ассистента и запись о негативном изменении баланса.
# backend/ai/service.py
from openai import AsyncOpenAI
from typing import AsyncGenerator
import json
from sqlalchemy.ext.asyncio import AsyncSession
from models import Message, CreditLedger
from schemas import ChatCompletionRequest, ChatStreamChunk
import redis.asyncio as redis
client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY)
redis_client = redis.from_url(settings.REDIS_URL)
class AIService:
def __init__(self, db: AsyncSession):
self.db = db
async def stream_chat(
self,
user_id: str,
conversation_id: str,
messages: list[dict],
model: str = "gpt-4o"
) -> AsyncGenerator[str, None]:
"""
Streams chat completion and deducts credits.
Yields SSE-formatted strings.
"""
# 1. Check rate limit
key = f"ratelimit:chat:{user_id}"
current = await redis_client.incr(key)
if current == 1:
await redis_client.expire(key, 60)
if current > 20: # 20 requests per minute
yield f"data: {json.dumps({'error': 'Rate limit exceeded'})}\n\n"
return
# 2. Check credits (simplified; use ledger sum in production)
balance = await self.get_credit_balance(user_id)
if balance < 0.01:
yield f"data: {json.dumps({'error': 'Insufficient credits'})}\n\n"
return
# 3. Stream from OpenAI
try:
stream = await client.chat.completions.create(
model=model,
messages=messages,
stream=True,
max_tokens=4096,
)
full_content = ""
total_tokens = 0
async for chunk in stream:
delta = chunk.choices[0].delta.content or ""
full_content += delta
total_tokens += 1 # Approximate; use tiktoken for accuracy
yield f"data: {json.dumps({'chunk': delta, 'done': False})}\n\n"
# 4. Persist assistant message
assistant_msg = Message(
conversation_id=conversation_id,
role="assistant",
content=full_content,
token_count=total_tokens,
metadata={"model": model, "finish_reason": "stop"}
)
self.db.add(assistant_msg)
# 5. Deduct credits (approximate $0.002 per 1K tokens for GPT-4o)
cost = (total_tokens / 1000) * 0.002
ledger_entry = CreditLedger(
user_id=user_id,
amount=-cost,
description=f"Chat completion: {conversation_id}",
reference_id=str(conversation_id)
)
self.db.add(ledger_entry)
await self.db.commit()
yield f"data: {json.dumps({'done': True, 'tokens': total_tokens})}\n\n"
except Exception as e:
yield f"data: {json.dumps({'error': str(e)})}\n\n"
async def get_credit_balance(self, user_id: str) -> float:
# In production, use a SQL SUM query
result = await self.db.execute(
select(func.sum(CreditLedger.amount)).where(CreditLedger.user_id == user_id)
)
return result.scalar() or 0.00
Примечания: один токен на чанк — это приблизительное значение, поэтому используйте данные об использовании, предоставленные провайдером, или токенизатор. Закодированная цена носит иллюстративный характер и не является актуальной; загружайте цены из конфигурации. Кроме того, проверка баланса перед трансляцией и его вычитание после неё не являются атомарными операциями; зарезервируйте кредиты заранее или заблокируйте строку пользователя.
Эндпоинт сообщений
Роутер проверяет права владельца (код 404, а не 403, чтобы избежать разглашения ID), сохраняет сообщение пользователя и возвращает StreamingResponse:
# backend/api/conversations.py
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from auth.dependencies import get_current_user
from database import get_db
from ai.service import AIService
from models import Conversation, Message
from schemas import CreateMessageRequest
router = APIRouter(prefix="/conversations", tags=["conversations"])
@router.post("/{conversation_id}/messages")
async def create_message(
conversation_id: str,
request: CreateMessageRequest,
current_user = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
# Verify ownership
conversation = await db.get(Conversation, conversation_id)
if not conversation or conversation.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Conversation not found")
# Persist user message
user_msg = Message(
conversation_id=conversation_id,
role="user",
content=request.content
)
db.add(user_msg)
await db.commit()
# Build message history
messages = [
{"role": m.role, "content": m.content}
for m in conversation.messages
]
messages.append({"role": "user", "content": request.content})
# Stream response
ai_service = AIService(db)
return StreamingResponse(
ai_service.stream_chat(
user_id=str(current_user.id),
conversation_id=conversation_id,
messages=messages,
model=conversation.model
),
media_type="text/event-stream"
)
При использовании асинхронного SQLAlchemy изменение элементов conversation.messages с опозданием приводит к ошибке, поэтому необходимо загружать связанные данные заранее.
Глобальный лимит частоты запросов
Второе, более строгое ограничение в 100 запросов в минуту реализовано как промежуточный слой:
# backend/middleware/rate_limit.py
from fastapi import Request, HTTPException
from starlette.middleware.base import BaseHTTPMiddleware
import redis.asyncio as redis
redis_client = redis.from_url("redis://localhost:6379")
class RateLimitMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
# Skip rate limiting for auth routes
if request.url.path.startswith("/auth"):
return await call_next(request)
# Identify user (from JWT cookie or header)
user_id = request.state.user_id if hasattr(request.state, "user_id") else "anonymous"
key = f"ratelimit:global:{user_id}"
current = await redis_client.incr(key)
if current == 1:
await redis_client.expire(key, 60)
if current > 100: # 100 requests per minute globally
raise HTTPException(status_code=429, detail="Too many requests")
response = await call_next(request)
response.headers["X-RateLimit-Remaining"] = str(100 - current)
return response
Исключение HTTPException, возникающее внутри BaseHTTPMiddleware, не преобразуется в стандартный код 429 с помощью обработчиков FastAPI, поэтому вместо этого возвращается JSONResponse с кодом состояния 429. Фиксированные окна также позволяют на время увеличить нагрузку.
Фронтенд
Дерево App Router связывает страницы серверных компонентов с клиентскими компонентами для реализации чата в реальном времени:
src/
├── app/
│ ├── layout.tsx
│ ├── page.tsx
│ ├── (dashboard)/
│ │ ├── layout.tsx
│ │ ├── conversations/
│ │ │ ├── page.tsx # Server Component: list conversations
│ │ │ └── [id]/
│ │ │ ├── page.tsx # Server Component: load initial messages
│ │ │ └── ChatClient.tsx # Client Component: streaming + input
│ │ └── settings/
│ │ └── page.tsx
│ └── api/ # Route handlers if needed
├── components/
│ ├── ui/ # shadcn/ui components
│ └── chat/
│ ├── MessageList.tsx
│ ├── MessageItem.tsx
│ └── StreamingText.tsx
├── lib/
│ ├── api.ts # Axios instance with interceptors
│ └── auth.ts
├── hooks/
│ ├── useChatStream.ts # Custom hook for SSE
│ └── useConversations.ts
└── providers.tsx
Загрузка истории на сервере
Страница считывает куки и одновременно запрашивает информацию о разговоре и сообщениях, поэтому первое отрисовывание уже содержит чат:
// app/(dashboard)/conversations/[id]/page.tsx
import { notFound } from "next/navigation";
import { cookies } from "next/headers";
import { ChatClient } from "./ChatClient";
async function getConversation(id: string, token: string) {
const res = await fetch(`${process.env.API_URL}/conversations/${id}`, {
headers: { Authorization: `Bearer ${token}` },
next: { revalidate: 0 } // Dynamic data
});
if (!res.ok) return null;
return res.json();
}async function getMessages(id: string, token: string) {
const res = await fetch(`${process.env.API_URL}/conversations/${id}/messages`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return [];
return res.json();
}export default async function ConversationPage({
params
}: {
params: { id: string }
}) {
const cookieStore = cookies();
const token = cookieStore.get("access_token")?.value;
if (!token) return notFound();
// Parallel data fetching on the server
const [conversation, messages] = await Promise.all([
getConversation(params.id, token),
getMessages(params.id, token)
]);
if (!conversation) return notFound();
return (
<div className="flex h-[calc(100vh-4rem)] flex-col">
<header className="border-b px-6 py-4">
<h1 className="text-lg font-semibold">{conversation.title || "New Chat"}</h1>
</header>
{/* Client component handles interactivity and streaming */}
<ChatClient
conversationId={params.id}
initialMessages={messages}
/>
</div>
);
}
В Next.js 15 и новее версиях функции cookies() и params являются асинхронными; необходимо использовать оператор await для их вызова.
Стриминг данных в интерфейс
Клиент оптимистично добавляет сообщение пользователя и пустой заместитель для ассистента, отправляет запрос типа POST и заполняет заместитель из потока ответа:
// app/(dashboard)/conversations/[id]/ChatClient.tsx
"use client";import { useState, useRef, useCallback } from "react";
import { useChatStream } from "@/hooks/useChatStream";
import { MessageList } from "@/components/chat/MessageList";
import { ChatInput } from "@/components/chat/ChatInput";
interface Message {
id: string;
role: "user" | "assistant";
content: string;
isStreaming?: boolean;
}
export function ChatClient({
conversationId,
initialMessages
}: {
conversationId: string;
initialMessages: Message[];
}) {
const [messages, setMessages] = useState<Message[]>(initialMessages);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = useCallback(async (content: string) => {
if (!content.trim() || isLoading) return;
// 1. Optimistic UI: Add user message immediately
const userMessage: Message = {
id: `temp-${Date.now()}`,
role: "user",
content
};
const assistantMessage: Message = {
id: `temp-assistant-${Date.now()}`,
role: "assistant",
content: "",
isStreaming: true
};
setMessages(prev => [...prev, userMessage, assistantMessage]);
setInput("");
setIsLoading(true);
// 2. Open SSE connection
const response = await fetch(`/api/conversations/${conversationId}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content })
});
if (!response.body) {
setIsLoading(false);
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let assistantContent = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split("\n");
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = JSON.parse(line.slice(6));
if (data.error) {
throw new Error(data.error);
}
if (data.chunk) {
assistantContent += data.chunk;
setMessages(prev =>
prev.map(msg =>
msg.id === assistantMessage.id
? { ...msg, content: assistantContent }
: msg
)
);
}
if (data.done) {
setMessages(prev =>
prev.map(msg =>
msg.id === assistantMessage.id
? { ...msg, isStreaming: false, tokenCount: data.tokens }
: msg
)
);
}
}
}
}
} catch (error) {
setMessages(prev =>
prev.map(msg =>
msg.id === assistantMessage.id
? { ...msg, content: "Error: Failed to generate response", isStreaming: false }
: msg
)
);
} finally {
setIsLoading(false);
reader.releaseLock();
}
}, [conversationId, isLoading]);
return (
<div className="flex flex-1 flex-col">
<MessageList messages={messages} />
<ChatInput
value={input}
onChange={setInput}
onSubmit={handleSubmit}
disabled={isLoading}
/>
</div>
);
}
Несмотря на комментарий, здесь используется fetch с читателем потока, а не EventSource, который не может отправлять тело запроса типа POST. Необходимо хранить частичные строки в буфере, поскольку куски данных могут их разделить, а также использовать AbortController для отмены операции при демонтировании. Абсолютный URL вида /api/... также предполагает наличие обработчика маршрута или правила переименования, которые проксируют запросы к FastAPI.
Клиент Axios для всего остального
С использованием cookie типа httpOnly клиент отправляет учетные данные, добавляет идентификатор отслеживания и повторяет попытку после обновления страницы при получении ответа 401:
// lib/api.ts
import axios from "axios";
export const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
withCredentials: true, // Send cookies
});
// Request interceptor: add auth token
api.interceptors.request.use((config) => {
// Token is in httpOnly cookie, sent automatically
// Add request ID for tracing
config.headers["X-Request-ID"] = crypto.randomUUID();
return config;
});
// Response interceptor: handle 401 and token refresh
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
await axios.post(
`${process.env.NEXT_PUBLIC_API_URL}/auth/refresh`,
{},
{ withCredentials: true }
);
return api(originalRequest);
} catch (refreshError) {
window.location.href = "/login";
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
Каждый параллельный запрос с кодом 401 вызывает обновление; для версии без конфликтов смотрите решение проблемы обновления.
Обеспечение соблюдения лимитов кредитов как зависимость
Зависимость FastAPI суммирует данные из журнала операций и отклоняет запрос с кодом 402 Payment Required, если баланс слишком низкий, ещё до любого вызова модели:
# backend/api/deps.py
from fastapi import Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from auth.dependencies import get_current_user
from database import get_db
from models import CreditLedger
from sqlalchemy import func, select
async def require_credits(
min_credits: float = 0.01,
current_user = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
result = await db.execute(
select(func.sum(CreditLedger.amount)).where(
CreditLedger.user_id == current_user.id
)
)
balance = result.scalar() or 0.00
if balance < min_credits:
raise HTTPException(
status_code=402, # Payment Required
detail=f"Insufficient credits. Balance: ${balance:.4f}"
)
return current_user
# Usage
@router.post("/conversations/{id}/messages")
async def create_message(
...,
user = Depends(require_credits) # Enforces payment before AI call
):
...
Она имеет ту же проблему нэт-атомарности, что и вышеописанный случай.
Тестирование без вызова модели
MSW 2.x возвращает фиктивный поток событий, а тест из Testing Library проверяет оптимистичные данные и текст потока:
// tests/mocks/openai.ts
import { http, HttpResponse } from "msw";
export const openaiHandlers = [
http.post("https://api.openai.com/v1/chat/completions", async () => {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
const chunks = [
'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n',
'data: {"choices":[{"delta":{"content":" world"}}]}\n\n',
'data: {"choices":[{"delta":{}}],"done":true}\n\n',
];
chunks.forEach(chunk => {
controller.enqueue(encoder.encode(chunk));
});
controller.close();
}
});
return new HttpResponse(stream, {
headers: {
"Content-Type": "text/event-stream",
},
});
}),
];
// tests/ChatClient.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ChatClient } from "@/app/(dashboard)/conversations/[id]/ChatClient";
it("streams assistant response", async () => {
const user = userEvent.setup();
render(
<ChatClient
conversationId="test-id"
initialMessages={[]}
/>
);
await user.type(screen.getByPlaceholderText("Ask anything..."), "Hello");
await user.click(screen.getByRole("button", { name: /send/i }));
// Optimistic user message appears immediately
expect(screen.getByText("Hello")).toBeInTheDocument();
// Streaming response appears
await waitFor(() => {
expect(screen.getByText("Hello world")).toBeInTheDocument();
});
});
Обработчик имитирует URL-адрес поставщика, но компонент обращается к вашему API, поэтому имитируйте свой конечный пункт вместо этого. Тест бэкенда модифицирует клиент и проверяет наличие одной строки в журнале операций:
# tests/test_chat.py
import pytest
from unittest.mock import AsyncMock, patch
@pytest.mark.asyncio
async def test_chat_deducts_credits(client, auth_headers, db_session):
with patch("ai.service.client.chat.completions.create") as mock_create:
mock_create.return_value = AsyncMock()
mock_create.return_value.__aiter__.return_value = [
MockChunk(delta=MockContent("Hi"))
]
response = await client.post(
"/conversations/test-id/messages",
json={"content": "Hello"},
headers=auth_headers
)
assert response.status_code == 200
# Verify credit deduction
ledger = await db_session.execute(
select(CreditLedger).where(CreditLedger.user_id == test_user_id)
)
assert len(ledger.scalars().all()) == 1
Развертывание с использованием контейнеров
Mногоэтапный Dockerfile собирает Next.js в режиме автономной работы и запускает его от имени пользователя, не являющегося root:
# frontend/Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]
Compose запускает PostgreSQL с библиотекой pgvector и проверкой работоспособности, Redis, API, фронтенд и nginx:
version: "3.8"
services:
postgres:
image: pgvector/pgvector:pg16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: saas_db
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
backend:
build: ./backend
environment:
DATABASE_URL: postgresql+asyncpg://app:${DB_PASSWORD}@postgres/saas_db
REDIS_URL: redis://redis:6379/0
OPENAI_API_KEY: ${OPENAI_API_KEY}
SECRET_KEY: ${SECRET_KEY}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
command: uvicorn main:app --host 0.0.0.0 --port 8000
frontend:
build: ./frontend
environment:
NEXT_PUBLIC_API_URL: http://backend:8000
depends_on:
- backend
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- frontend
- backend
volumes:
postgres_data:
redis_data:
NEXT_PUBLIC_ значения закомпилируются во время сборки для браузера, который не может разрешить адрес backend:8000; используйте публичный маршрут nginx.
Основные выводы
- Храните ключи, цены и лимиты на стороне сервера; модель является зависимостью, расположенной за вашим API.
- Записывайте данные использования в журнал с возможностью только добавления записей и делайте процесс проверки и вычета атомарным.
- Используйте потоковую передачу с явным отменением операций и буферизацией по строкам.
- В тестах имитируйте собственный контракт API, а не контракт поставщика.