Phase 10: Per-User Rate Limits — messages + tokens, quota UI, admin usage

Backend:
- max_ai_messages_per_day + max_ai_tokens_per_day on User model (nullable, override)
- Migration 008: add columns + seed default settings (100 msgs, 500K tokens)
- usage_service: count today's messages + tokens, check quota, get limits
- GET /chats/quota returns usage vs limits + reset time
- POST /chats/{id}/messages checks quota before streaming (429 if exceeded)
- Admin user schemas expose both limit fields
- GET /admin/usage returns per-user daily message + token counts
- admin_user_service allows updating both limit fields

Frontend:
- Chat header shows "X/Y messages · XK/YK tokens" with red highlight at limit
- Quota refreshes every 30s via TanStack Query
- Admin usage page with table: user, messages today, tokens today
- Route + sidebar entry for admin usage
- English + Russian translations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 15:44:51 +03:00
parent bb53eeee8e
commit d86d53f473
17 changed files with 390 additions and 17 deletions

View File

@@ -0,0 +1,34 @@
"""Add AI rate limit columns to users, seed default settings
Revision ID: 008
Revises: 007
Create Date: 2026-03-19
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "008"
down_revision: Union[str, None] = "007"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("users", sa.Column("max_ai_messages_per_day", sa.Integer, nullable=True))
op.add_column("users", sa.Column("max_ai_tokens_per_day", sa.Integer, nullable=True))
op.execute("""
INSERT INTO settings (id, key, value) VALUES
(gen_random_uuid(), 'default_max_ai_messages_per_day', '100'),
(gen_random_uuid(), 'default_max_ai_tokens_per_day', '500000')
ON CONFLICT (key) DO NOTHING
""")
def downgrade() -> None:
op.drop_column("users", "max_ai_tokens_per_day")
op.drop_column("users", "max_ai_messages_per_day")
op.execute("DELETE FROM settings WHERE key IN ('default_max_ai_messages_per_day', 'default_max_ai_tokens_per_day')")

View File

@@ -218,3 +218,32 @@ async def preview_pdf_template(
):
html = await pdf_template_service.render_preview(data.html_content)
return PreviewResponse(html=html)
# --- Usage Stats ---
@router.get("/usage")
async def get_usage_stats(
_admin: Annotated[User, Depends(require_admin)],
db: Annotated[AsyncSession, Depends(get_db)],
):
from sqlalchemy import select, func
from app.models.user import User as UserModel
from app.services.usage_service import get_user_message_count_today, get_user_token_count_today, get_user_daily_limits
result = await db.execute(select(UserModel).where(UserModel.is_active == True).order_by(UserModel.username)) # noqa: E712
users = result.scalars().all()
stats = []
for u in users:
limits = await get_user_daily_limits(db, u)
stats.append({
"user_id": str(u.id),
"username": u.username,
"messages_today": await get_user_message_count_today(db, u.id),
"message_limit": limits["message_limit"],
"tokens_today": await get_user_token_count_today(db, u.id),
"token_limit": limits["token_limit"],
})
return {"users": stats}

View File

@@ -19,10 +19,19 @@ from app.schemas.chat import (
)
from app.services import chat_service, skill_service
from app.services.ai_service import stream_ai_response
from app.services.usage_service import check_user_quota
router = APIRouter(prefix="/chats", tags=["chats"])
@router.get("/quota")
async def get_quota(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
return await check_user_quota(db, user)
@router.post("/", response_model=ChatResponse, status_code=status.HTTP_201_CREATED)
async def create_chat(
data: CreateChatRequest,
@@ -96,6 +105,13 @@ async def send_message(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
from fastapi import HTTPException
quota = await check_user_quota(db, user)
if quota["messages_exceeded"]:
raise HTTPException(status_code=429, detail=f"Daily message limit reached ({quota['message_limit']}). Resets at {quota['resets_at']}.")
if quota["tokens_exceeded"]:
raise HTTPException(status_code=429, detail=f"Daily token limit reached ({quota['token_limit']}). Resets at {quota['resets_at']}.")
return StreamingResponse(
stream_ai_response(db, chat_id, user.id, data.content),
media_type="text/event-stream",

View File

@@ -16,6 +16,8 @@ class User(Base):
role: Mapped[str] = mapped_column(String(20), nullable=False, default="user")
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
max_chats: Mapped[int] = mapped_column(Integer, nullable=False, default=10)
max_ai_messages_per_day: Mapped[int | None] = mapped_column(Integer, nullable=True)
max_ai_tokens_per_day: Mapped[int | None] = mapped_column(Integer, nullable=True)
oauth_provider: Mapped[str | None] = mapped_column(String(50), nullable=True)
oauth_provider_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
telegram_chat_id: Mapped[int | None] = mapped_column(nullable=True)

View File

@@ -14,6 +14,8 @@ class AdminUserResponse(BaseModel):
role: str
is_active: bool
max_chats: int
max_ai_messages_per_day: int | None
max_ai_tokens_per_day: int | None
created_at: datetime
model_config = {"from_attributes": True}
@@ -26,12 +28,16 @@ class AdminUserCreateRequest(BaseModel):
full_name: str | None = None
role: Literal["user", "admin"] = "user"
max_chats: int = 10
max_ai_messages_per_day: int | None = None
max_ai_tokens_per_day: int | None = None
class AdminUserUpdateRequest(BaseModel):
role: Literal["user", "admin"] | None = None
is_active: bool | None = None
max_chats: int | None = None
max_ai_messages_per_day: int | None = None
max_ai_tokens_per_day: int | None = None
full_name: str | None = None

View File

@@ -49,7 +49,7 @@ async def create_user(
async def update_user(db: AsyncSession, user_id: uuid.UUID, **kwargs) -> User:
user = await get_user(db, user_id)
allowed = {"role", "is_active", "max_chats", "full_name"}
allowed = {"role", "is_active", "max_chats", "max_ai_messages_per_day", "max_ai_tokens_per_day", "full_name"}
for key, value in kwargs.items():
if key in allowed and value is not None:
setattr(user, key, value)

View File

@@ -0,0 +1,74 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import func, select, cast, Integer
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.chat import Chat
from app.models.message import Message
from app.models.user import User
from app.services.setting_service import get_setting_value
def _today_start() -> datetime:
return datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
async def get_user_message_count_today(db: AsyncSession, user_id: uuid.UUID) -> int:
result = await db.scalar(
select(func.count())
.select_from(Message)
.join(Chat, Message.chat_id == Chat.id)
.where(Chat.user_id == user_id, Message.role == "user", Message.created_at >= _today_start())
)
return result or 0
async def get_user_token_count_today(db: AsyncSession, user_id: uuid.UUID) -> int:
"""Sum input_tokens + output_tokens from assistant message metadata today."""
result = await db.execute(
select(Message.metadata_)
.join(Chat, Message.chat_id == Chat.id)
.where(
Chat.user_id == user_id,
Message.role == "assistant",
Message.created_at >= _today_start(),
Message.metadata_.isnot(None),
)
)
total = 0
for (meta,) in result:
if meta and isinstance(meta, dict):
total += int(meta.get("input_tokens", 0)) + int(meta.get("output_tokens", 0))
return total
async def get_user_daily_limits(db: AsyncSession, user: User) -> dict:
msg_limit = user.max_ai_messages_per_day
if msg_limit is None:
msg_limit = int(await get_setting_value(db, "default_max_ai_messages_per_day", 100))
token_limit = user.max_ai_tokens_per_day
if token_limit is None:
token_limit = int(await get_setting_value(db, "default_max_ai_tokens_per_day", 500000))
return {"message_limit": msg_limit, "token_limit": token_limit}
async def check_user_quota(db: AsyncSession, user: User) -> dict:
"""Check quota and return status. Raises nothing — caller decides."""
limits = await get_user_daily_limits(db, user)
msg_count = await get_user_message_count_today(db, user.id)
token_count = await get_user_token_count_today(db, user.id)
tomorrow = _today_start().replace(day=_today_start().day + 1)
return {
"messages_used": msg_count,
"message_limit": limits["message_limit"],
"tokens_used": token_count,
"token_limit": limits["token_limit"],
"messages_exceeded": msg_count >= limits["message_limit"],
"tokens_exceeded": token_count >= limits["token_limit"],
"resets_at": tomorrow.isoformat(),
}