diff --git a/api/src/api/v1/websocket.py b/api/src/api/v1/websocket.py index 2f805c9..c4902c6 100644 --- a/api/src/api/v1/websocket.py +++ b/api/src/api/v1/websocket.py @@ -365,13 +365,71 @@ class ConnectionManager: self.cell_locks.pop(lock_key, None) +# AURORA-1845: срок жизни кэша пользователя/доступа к форме внутри одного +# WebSocket-соединения. Ограничивает устаревание (смена роли/орг-юнитов, +# удаление формы) при живом соединении; источник истины по правам - +# can_edit в SQL-обёртках, он проверяется на каждое сообщение. +CONNECTION_CACHE_TTL_SECONDS = 30.0 + + +class ConnectionCache: + """Кэш на время соединения: пользователь и факт доступа к форме. + + Хранится вне процессора (процессор создаётся на каждое сообщение вместе + с новой сессией БД). Кэшируются только положительные результаты. + """ + + def __init__(self, ttl: float = CONNECTION_CACHE_TTL_SECONDS, clock=time.monotonic): + self.ttl = ttl + self._clock = clock + self._user: tuple[float, AppUser] | None = None + self._forms: dict[int, float] = {} + + def get_user(self, user_id: int) -> AppUser | None: + if self._user is None: + return None + ts, user = self._user + if user.id != user_id or self._clock() - ts >= self.ttl: + self._user = None + return None + return user + + def set_user(self, user: AppUser | None) -> None: + if user is not None: + self._user = (self._clock(), user) + + def form_access_ok(self, form_id: int) -> bool: + ts = self._forms.get(form_id) + if ts is None: + return False + if self._clock() - ts >= self.ttl: + self._forms.pop(form_id, None) + return False + return True + + def set_form_access(self, form_id: int) -> None: + self._forms[form_id] = self._clock() + + class FormEventProcess: def __init__(self, db: AsyncSession): self.sheet_service: SheetService = SheetService(db) self.bf_service: BudgetFormService = BudgetFormService(db) self.bl_service: BudgetLineService = BudgetLineService(db) self.user_service: UserService = UserService(db) + self._cache: ConnectionCache | None = None + async def _check_form_access(self, form_id: int, user: AppUser) -> bool: + """Доступ пользователя к форме; положительный результат кэшируется.""" + cache = self._cache + if cache and cache.form_access_ok(form_id): + return True + form = await self.bf_service.get(budget_form_id=form_id, user=user) + if not form: + return False + if cache: + cache.set_form_access(form_id) + return True async def process( self, @@ -380,8 +438,14 @@ class FormEventProcess: user_id: int, sheet: str, direction: str | None = None, + cache: ConnectionCache | None = None, ) -> int | bool | dict: - curr_user: AppUser = await self.user_service.get(user_id) + self._cache = cache + curr_user: AppUser | None = cache.get_user(user_id) if cache else None + if curr_user is None: + curr_user = await self.user_service.get(user_id) + if cache: + cache.set_user(curr_user) match event_data["event"]: case "cell_updated": @@ -458,8 +522,7 @@ class FormEventProcess: "value": any, } """ - form = await self.bf_service.get(budget_form_id=form_id, user=user) - if not form: + if not await self._check_form_access(form_id=form_id, user=user): return None return await self.sheet_service.update_cell( @@ -495,8 +558,7 @@ class FormEventProcess: contract_end_date: Optional[datetime] = None } """ - form = await self.bf_service.get(budget_form_id=form_id, user=user) - if not form: + if not await self._check_form_access(form_id=form_id, user=user): return None ids = await self.bl_service.get_ids(budget_form_id=form_id) @@ -617,8 +679,7 @@ class FormEventProcess: "row_id": int, } """ - form = await self.bf_service.get(budget_form_id=form_id, user=user) - if not form: + if not await self._check_form_access(form_id=form_id, user=user): return None result = await self.sheet_service.delete_line( form_id=form_id, @@ -970,6 +1031,8 @@ async def process_websocket( ) return + conn_cache = ConnectionCache() + while True: data = loads(await websocket.receive_text()) @@ -1068,10 +1131,13 @@ async def process_websocket( processor = processor_cls(db) t0 = time.perf_counter() data_old = data.copy() + process_kwargs = dict(kwargs_process) + if processor_cls is FormEventProcess: + process_kwargs["cache"] = conn_cache data["result"] = await processor.process( event_data=data, user_id=user_id, - **kwargs_process + **process_kwargs ) should_refresh_ahr = ( con_key == ConnectionKeyEnum.FORM