Реал-тайм через SSE (push-to-invalidate): живые приглашения/партии, авто-обновление без перезагрузки
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ from app.services import (
|
||||
audit_service,
|
||||
faction_service,
|
||||
match_service,
|
||||
notify,
|
||||
user_service,
|
||||
)
|
||||
|
||||
@@ -240,6 +241,7 @@ def update_match(
|
||||
ip=request.client.host if request.client else None,
|
||||
)
|
||||
session.commit()
|
||||
notify.match_changed(session, match)
|
||||
return build_match_read(session, match, can_modify=True)
|
||||
|
||||
|
||||
@@ -289,12 +291,14 @@ def delete_match(
|
||||
session: Session = Depends(get_session),
|
||||
admin: User = Depends(get_current_admin),
|
||||
) -> s.OkResponse:
|
||||
group_id = match_service.get_match(session, match_id).group_id # для уведомления
|
||||
admin_service.delete_match(session, match_id)
|
||||
audit_service.record(
|
||||
session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id,
|
||||
ip=request.client.host if request.client else None,
|
||||
)
|
||||
session.commit()
|
||||
notify.match_removed(session, match_id, group_id)
|
||||
return s.OkResponse()
|
||||
|
||||
|
||||
@@ -330,6 +334,7 @@ def admin_add_attachment(
|
||||
att = attachment_service.add_photo(
|
||||
session, match, admin, content, ext, user_service.avatar_media_type(ext)
|
||||
)
|
||||
notify.match_changed(session, match)
|
||||
return attachment_read(att, f"/api/admin/matches/{match_id}")
|
||||
|
||||
|
||||
@@ -342,6 +347,7 @@ def admin_delete_attachment(
|
||||
) -> s.OkResponse:
|
||||
match = match_service.get_match(session, match_id)
|
||||
attachment_service.delete(session, match, attachment_id)
|
||||
notify.match_changed(session, match)
|
||||
return s.OkResponse()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""SSE: поток событий «данные изменились» для текущего пользователя.
|
||||
|
||||
Клиент (EventSource) держит одно соединение и по событию точечно перезапрашивает данные
|
||||
(push-to-invalidate). Мутации остаются обычным REST.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.auth.deps import get_current_user
|
||||
from app.core.events import hub
|
||||
from app.models import User
|
||||
|
||||
router = APIRouter(tags=["events"])
|
||||
|
||||
_HEARTBEAT_SECONDS = 25
|
||||
|
||||
|
||||
@router.get("/events")
|
||||
async def events(request: Request, user: User = Depends(get_current_user)) -> StreamingResponse:
|
||||
queue = hub.subscribe(user.id) # type: ignore[arg-type]
|
||||
|
||||
async def stream():
|
||||
try:
|
||||
yield ": connected\n\n"
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
try:
|
||||
event = await asyncio.wait_for(queue.get(), timeout=_HEARTBEAT_SECONDS)
|
||||
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
||||
except asyncio.TimeoutError:
|
||||
yield ": ping\n\n" # heartbeat против idle-таймаутов прокси/туннеля
|
||||
finally:
|
||||
hub.unsubscribe(user.id, queue) # type: ignore[arg-type]
|
||||
|
||||
return StreamingResponse(
|
||||
stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
@@ -14,6 +14,7 @@ from app.services import (
|
||||
group_service,
|
||||
invitation_service,
|
||||
membership_service,
|
||||
notify,
|
||||
stats_service,
|
||||
)
|
||||
|
||||
@@ -153,6 +154,7 @@ def invite_member(
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
notify.invitations_changed(invited.id) # type: ignore[arg-type] # живое появление у получателя
|
||||
return s.InvitationRead(
|
||||
id=inv.id, # type: ignore[arg-type]
|
||||
group_id=group_id,
|
||||
@@ -174,6 +176,7 @@ def remove_member(
|
||||
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
|
||||
group = group_service.get_group(session, group_id)
|
||||
membership_service.remove_member(session, group, user_id)
|
||||
notify.group_changed(session, group_id, extra_user_ids=[user_id]) # + удалённому
|
||||
return s.OkResponse()
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from app.auth.deps import get_current_user
|
||||
from app.db.session import get_session
|
||||
from app.models import User
|
||||
from app.schemas import api as s
|
||||
from app.services import invitation_service
|
||||
from app.services import invitation_service, notify
|
||||
|
||||
router = APIRouter(prefix="/invitations", tags=["invitations"])
|
||||
|
||||
@@ -27,7 +27,9 @@ def accept_invitation(
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.OkResponse:
|
||||
invitation_service.accept(session, user, invitation_id)
|
||||
group = invitation_service.accept(session, user, invitation_id)
|
||||
notify.invitations_changed(user.id) # type: ignore[arg-type]
|
||||
notify.group_changed(session, group.id) # type: ignore[arg-type] # новый участник
|
||||
return s.OkResponse()
|
||||
|
||||
|
||||
@@ -38,4 +40,5 @@ def decline_invitation(
|
||||
user: User = Depends(get_current_user),
|
||||
) -> s.OkResponse:
|
||||
invitation_service.decline(session, user, invitation_id)
|
||||
notify.invitations_changed(user.id) # type: ignore[arg-type]
|
||||
return s.OkResponse()
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.services import (
|
||||
audit_service,
|
||||
group_service,
|
||||
match_service,
|
||||
notify,
|
||||
user_service,
|
||||
)
|
||||
from app.services.match_service import FinishInput, ParticipantInput, RosterInput
|
||||
@@ -119,6 +120,7 @@ def start_match(
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
notify.match_changed(session, match)
|
||||
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
|
||||
|
||||
|
||||
@@ -157,6 +159,7 @@ def finish_match(
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
notify.match_changed(session, match)
|
||||
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
|
||||
|
||||
|
||||
@@ -214,6 +217,7 @@ def update_match(
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
notify.match_changed(session, match)
|
||||
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
|
||||
|
||||
|
||||
@@ -243,6 +247,7 @@ def add_attachment(
|
||||
att = attachment_service.add_photo(
|
||||
session, match, user, content, ext, user_service.avatar_media_type(ext)
|
||||
)
|
||||
notify.match_changed(session, match)
|
||||
return attachment_read(att, f"/api/matches/{match_id}")
|
||||
|
||||
|
||||
@@ -256,6 +261,7 @@ def delete_attachment(
|
||||
match = match_service.get_match(session, match_id)
|
||||
_assert_can_attach(session, match, user)
|
||||
attachment_service.delete(session, match, attachment_id)
|
||||
notify.match_changed(session, match)
|
||||
return s.OkResponse()
|
||||
|
||||
|
||||
@@ -301,4 +307,5 @@ def delete_match(
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
session.commit()
|
||||
notify.match_removed(session, match_id_val, group_id_val) # type: ignore[arg-type]
|
||||
return s.OkResponse()
|
||||
|
||||
Reference in New Issue
Block a user