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:
34
backend/alembic/versions/008_add_user_ai_rate_limit.py
Normal file
34
backend/alembic/versions/008_add_user_ai_rate_limit.py
Normal 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')")
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
74
backend/app/services/usage_service.py
Normal file
74
backend/app/services/usage_service.py
Normal 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(),
|
||||
}
|
||||
Reference in New Issue
Block a user