Compare commits
5 commits
7377fb61e5
...
ed12d9e657
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed12d9e657 | ||
|
|
5eadae1e95 | ||
| c9ba1b15ba | |||
| 06bf8dfada | |||
| e0b63cc637 |
17 changed files with 1221 additions and 485 deletions
|
|
@ -15,6 +15,20 @@ support-моста (`app.services.tgbot.bridge`, data/sql/186_tg_support.sql).
|
||||||
|
|
||||||
Копия зеркала в топике всегда помечена "[С САЙТА] <username>: ..." — оператор
|
Копия зеркала в топике всегда помечена "[С САЙТА] <username>: ..." — оператор
|
||||||
не должен путать веб-обращение с Telegram-клиентом (#tgsupport-web AC).
|
не должен путать веб-обращение с Telegram-клиентом (#tgsupport-web AC).
|
||||||
|
|
||||||
|
КРИТИЧНО (review H1) — порядок операций в `send_support_message`:
|
||||||
|
БД-запись (`get_or_create_thread`) идёт ПОСЛЕ успешного `send_message`, не до.
|
||||||
|
Прод — один uvicorn-процесс БЕЗ `--workers` (docker-compose.prod.yml) с
|
||||||
|
синхронным SQLAlchemy engine (пул 5+10 overflow) на ОДНОМ event loop. Если бы
|
||||||
|
`INSERT ... ON CONFLICT DO UPDATE` уходил ДО Telegram-вызова, строка/row-lock
|
||||||
|
держались бы всё время, пока `send_message` ждёт Telegram (секунды-минуты при
|
||||||
|
429/5xx на воркерных ретраях) — второй параллельный запрос ТОГО ЖЕ юзера
|
||||||
|
(двойной клик, вторая вкладка) упёрся бы в этот lock ВНУТРИ синхронного
|
||||||
|
psycopg-вызова внутри `async def`, останавливая event loop целиком (весь API
|
||||||
|
встаёт, не только этот эндпоинт). `_format_mirror_text` использует только
|
||||||
|
`username` — thread_id для отправки не нужен вообще, поэтому эту БД-операцию
|
||||||
|
можно безопасно отложить до после успешного sendMessage. Бонус: неудачная
|
||||||
|
отправка больше не создаёт тред.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -42,20 +56,35 @@ MAX_MESSAGE_LENGTH = 4000
|
||||||
|
|
||||||
# Жёстче общего RateLimitMiddleware (300 req/60с на пользователя, app/main.py):
|
# Жёстче общего RateLimitMiddleware (300 req/60с на пользователя, app/main.py):
|
||||||
# бот-токен общий на ВСЕХ клиентов веб-чата, флуд одного клиента иначе может
|
# бот-токен общий на ВСЕХ клиентов веб-чата, флуд одного клиента иначе может
|
||||||
# упереться в Telegram-лимиты (`sendMessage` 429) и застопорить доставку всем
|
# упереться в Telegram-лимиты (`sendMessage` 429, лимит группы ~20 msg/min) и
|
||||||
# остальным (см. задачу, п.6). 12 сообщений/минуту — щедро для живого диалога,
|
# застопорить доставку всем остальным (см. задачу, п.6). 12 сообщений/минуту —
|
||||||
# но режет скрипт-флуд на порядок раньше общего API-лимита.
|
# щедро для живого диалога, но режет скрипт-флуд на порядок раньше общего API-лимита.
|
||||||
_SEND_RATE_LIMIT = 12
|
_SEND_RATE_LIMIT = 12
|
||||||
_SEND_RATE_WINDOW_S = 60.0
|
_SEND_RATE_WINDOW_S = 60.0
|
||||||
_send_limiter = SlidingWindowLimiter(limit=_SEND_RATE_LIMIT, window_s=_SEND_RATE_WINDOW_S)
|
_send_limiter = SlidingWindowLimiter(limit=_SEND_RATE_LIMIT, window_s=_SEND_RATE_WINDOW_S)
|
||||||
|
|
||||||
|
# #tgsupport-web review H1: интерактивный HTTP-запрос НЕ МОЖЕТ наследовать
|
||||||
|
# воркерную политику ретраев `TelegramClient` (по умолчанию — до 5 попыток, на
|
||||||
|
# 429 спит `retry_after` Telegram'а — для группы штатно 30-60с, на 5xx backoff до
|
||||||
|
# 30с — легальный суммарный бюджет минуты). Узкий бюджет здесь: 1 повтор, короткий
|
||||||
|
# timeout — интерактивный клиент должен получить ответ (даже если это ошибка)
|
||||||
|
# за секунды, а не висеть до исчерпания воркерных ретраев.
|
||||||
|
_INTERACTIVE_SEND_TIMEOUT_S = 10.0
|
||||||
|
_INTERACTIVE_SEND_MAX_RETRIES = 1
|
||||||
|
|
||||||
|
# #tgsupport-web review M5: без LIMIT каждое монтирование виджета на старом
|
||||||
|
# треде отдавало бы ВЕСЬ лог переписки. См. `web_support_storage.list_messages`.
|
||||||
|
_LIST_MESSAGES_LIMIT = 200
|
||||||
|
|
||||||
|
|
||||||
def _require_username(request: Request) -> str:
|
def _require_username(request: Request) -> str:
|
||||||
"""Достаёт X-Authenticated-User. rbac_guard (app/main.py) уже гарантирует его
|
"""Достаёт X-Authenticated-User. rbac_guard (app/main.py) уже гарантирует его
|
||||||
наличие в проде для non-public путей — этот guard здесь defence-in-depth и
|
наличие в проде для non-public путей — этот guard здесь defence-in-depth и
|
||||||
делает роутер тестируемым без поднятия всего app.main (см. tests/test_support.py,
|
делает роутер тестируемым без поднятия всего app.main (см. tests/test_support.py,
|
||||||
как test_trade_in_lead.py для /lead)."""
|
как test_trade_in_lead.py для /lead). `.strip()` (review L4) — лишний пробел
|
||||||
username = request.headers.get("x-authenticated-user")
|
от прокси иначе завёл бы ВТОРОЙ тред на, по сути, того же пользователя
|
||||||
|
(username — UNIQUE ключ треда, "alice" != "alice ")."""
|
||||||
|
username = (request.headers.get("x-authenticated-user") or "").strip()
|
||||||
if not username:
|
if not username:
|
||||||
raise HTTPException(status_code=401, detail="no authenticated user")
|
raise HTTPException(status_code=401, detail="no authenticated user")
|
||||||
return username
|
return username
|
||||||
|
|
@ -105,7 +134,9 @@ class StatusOut(BaseModel):
|
||||||
|
|
||||||
def _format_mirror_text(username: str, message_text: str) -> str:
|
def _format_mirror_text(username: str, message_text: str) -> str:
|
||||||
"""Помечает зеркало как пришедшее С САЙТА, от какого пользователя — оператор
|
"""Помечает зеркало как пришедшее С САЙТА, от какого пользователя — оператор
|
||||||
иначе не отличит веб-обращение от Telegram-клиента (#tgsupport-web AC)."""
|
иначе не отличит веб-обращение от Telegram-клиента (#tgsupport-web AC).
|
||||||
|
Использует ТОЛЬКО username — thread_id здесь не нужен (см. H1 в docstring
|
||||||
|
модуля), это то, что делает возможным отложить БД-запись до после отправки."""
|
||||||
return f"[С САЙТА] {username}:\n{message_text}"
|
return f"[С САЙТА] {username}:\n{message_text}"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -116,14 +147,21 @@ async def send_support_message(
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
) -> SupportMessageOut:
|
) -> SupportMessageOut:
|
||||||
"""Отправляет сообщение от лица *username* в support-топик (`sendMessage` —
|
"""Отправляет сообщение от лица *username* в support-топик (`sendMessage` —
|
||||||
не `copyMessage`: у веб-сообщения нет исходного Telegram-сообщения для копии)."""
|
не `copyMessage`: у веб-сообщения нет исходного Telegram-сообщения для копии).
|
||||||
|
|
||||||
|
Порядок операций см. H1 в docstring модуля: rate-limit проверяется (но НЕ
|
||||||
|
расходуется, review L3) до отправки, thread создаётся ТОЛЬКО после успешного
|
||||||
|
`send_message` — до этого момента с БД не происходит ничего.
|
||||||
|
"""
|
||||||
if not _bot_configured():
|
if not _bot_configured():
|
||||||
# Предсказуемое поведение вместо 500 (#tgsupport-web AC): бот не настроен
|
# Предсказуемое поведение вместо 500 (#tgsupport-web AC): бот не настроен
|
||||||
# (пустой TELEGRAM_BOT_TOKEN, dev/staging) или support-топик не задан —
|
# (пустой TELEGRAM_BOT_TOKEN, dev/staging) или support-топик не задан —
|
||||||
# мирроринг невозможен физически, ничего не пишем в БД.
|
# мирроринг невозможен физически, ничего не пишем в БД.
|
||||||
raise HTTPException(status_code=503, detail=SERVICE_UNAVAILABLE_TEXT)
|
raise HTTPException(status_code=503, detail=SERVICE_UNAVAILABLE_TEXT)
|
||||||
|
|
||||||
retry_after = _send_limiter.check(username)
|
# review L3: peek без расхода бюджета — неудачная отправка НЕ должна стоить
|
||||||
|
# пользователю попытки (расходуем `.record()` только на успех, ниже).
|
||||||
|
retry_after = _send_limiter.retry_after(username)
|
||||||
if retry_after is not None:
|
if retry_after is not None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=429,
|
status_code=429,
|
||||||
|
|
@ -131,14 +169,15 @@ async def send_support_message(
|
||||||
headers={"Retry-After": str(int(retry_after) + 1)},
|
headers={"Retry-After": str(int(retry_after) + 1)},
|
||||||
)
|
)
|
||||||
|
|
||||||
thread_id = storage.get_or_create_thread(db, username)
|
|
||||||
|
|
||||||
client = TelegramClient(settings.telegram_bot_token)
|
client = TelegramClient(settings.telegram_bot_token)
|
||||||
try:
|
try:
|
||||||
mirrored = await client.send_message(
|
mirrored = await client.send_message(
|
||||||
chat_id=settings.telegram_support_chat_id,
|
chat_id=settings.telegram_support_chat_id,
|
||||||
text=_format_mirror_text(username, payload.text),
|
text=_format_mirror_text(username, payload.text),
|
||||||
message_thread_id=settings.telegram_support_topic_id or None,
|
message_thread_id=settings.telegram_support_topic_id or None,
|
||||||
|
# review H1: узкий интерактивный бюджет — НЕ воркерные 5 ретраев/минуты.
|
||||||
|
timeout=_INTERACTIVE_SEND_TIMEOUT_S,
|
||||||
|
max_retries=_INTERACTIVE_SEND_MAX_RETRIES,
|
||||||
)
|
)
|
||||||
except TelegramApiError:
|
except TelegramApiError:
|
||||||
# НЕ логируем payload.text (переписка — ПДн) и НЕ логируем токен (его в
|
# НЕ логируем payload.text (переписка — ПДн) и НЕ логируем токен (его в
|
||||||
|
|
@ -148,13 +187,28 @@ async def send_support_message(
|
||||||
)
|
)
|
||||||
raise HTTPException(status_code=502, detail=SERVICE_UNAVAILABLE_TEXT) from None
|
raise HTTPException(status_code=502, detail=SERVICE_UNAVAILABLE_TEXT) from None
|
||||||
|
|
||||||
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
|
# Отправка удалась — теперь и только теперь расходуем rate-limit бюджет.
|
||||||
|
_send_limiter.record(username)
|
||||||
|
|
||||||
|
topic_message_id = mirrored.get("message_id") if isinstance(mirrored, dict) else None
|
||||||
|
if topic_message_id is None:
|
||||||
|
# review L1: без topic_message_id реплай оператора на это сообщение
|
||||||
|
# НИКОГДА не смаршрутизируется обратно (find_thread_by_topic_message ищет
|
||||||
|
# именно по этому полю) — тихая, но зафиксированная в логе деградация.
|
||||||
|
logger.warning(
|
||||||
|
"web support: Telegram sendMessage не вернул message_id (username=%s) — "
|
||||||
|
"ответ оператора на это сообщение не будет смаршрутизирован",
|
||||||
|
username,
|
||||||
|
)
|
||||||
|
|
||||||
|
# review H1: БД-операция ПОСЛЕ успешной отправки — см. docstring модуля.
|
||||||
|
thread_id = storage.get_or_create_thread(db, username)
|
||||||
row = storage.record_inbound(
|
row = storage.record_inbound(
|
||||||
db,
|
db,
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
text_body=payload.text,
|
text_body=payload.text,
|
||||||
topic_message_id=topic_message_id,
|
topic_message_id=topic_message_id,
|
||||||
|
support_chat_id=settings.telegram_support_chat_id,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
@ -163,25 +217,34 @@ async def send_support_message(
|
||||||
|
|
||||||
|
|
||||||
@router.get("/support/messages", response_model=list[SupportMessageOut])
|
@router.get("/support/messages", response_model=list[SupportMessageOut])
|
||||||
async def list_support_messages(
|
def list_support_messages(
|
||||||
username: Annotated[str, Depends(_require_username)],
|
username: Annotated[str, Depends(_require_username)],
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
since: Annotated[int, Query(ge=0)] = 0,
|
since: Annotated[int, Query(ge=0)] = 0,
|
||||||
) -> list[SupportMessageOut]:
|
) -> list[SupportMessageOut]:
|
||||||
"""Сообщения СВОЕГО треда с id > since. Тред резолвится по username — чужой
|
"""Сообщения СВОЕГО треда с id > since. Тред резолвится по username — чужой
|
||||||
тред недостижим (нет параметра, которым его можно адресовать)."""
|
тред недостижим (нет параметра, которым его можно адресовать).
|
||||||
|
|
||||||
|
Обычный (sync) `def`, не `async def` (review M3): тело — только синхронные
|
||||||
|
psycopg-вызовы, ни одного `await`; как `async def` это исполнялось бы прямо в
|
||||||
|
event loop (а фронт поллит эту ручку постоянно). Starlette гонит sync-handlers
|
||||||
|
в threadpool автоматически — тот же паттерн, что `trade_in.py:get_estimate`.
|
||||||
|
"""
|
||||||
thread_id = storage.find_thread_id(db, username)
|
thread_id = storage.find_thread_id(db, username)
|
||||||
if thread_id is None:
|
if thread_id is None:
|
||||||
return []
|
return []
|
||||||
rows = storage.list_messages(db, thread_id=thread_id, since_id=since)
|
rows = storage.list_messages(
|
||||||
|
db, thread_id=thread_id, since_id=since, limit=_LIST_MESSAGES_LIMIT
|
||||||
|
)
|
||||||
return [SupportMessageOut(**r) for r in rows]
|
return [SupportMessageOut(**r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/support/unread", response_model=UnreadOut)
|
@router.get("/support/unread", response_model=UnreadOut)
|
||||||
async def get_support_unread(
|
def get_support_unread(
|
||||||
username: Annotated[str, Depends(_require_username)],
|
username: Annotated[str, Depends(_require_username)],
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
) -> UnreadOut:
|
) -> UnreadOut:
|
||||||
|
"""Sync `def` (review M3) — см. `list_support_messages`."""
|
||||||
thread_id = storage.find_thread_id(db, username)
|
thread_id = storage.find_thread_id(db, username)
|
||||||
if thread_id is None:
|
if thread_id is None:
|
||||||
return UnreadOut(unread=0)
|
return UnreadOut(unread=0)
|
||||||
|
|
@ -189,10 +252,11 @@ async def get_support_unread(
|
||||||
|
|
||||||
|
|
||||||
@router.post("/support/read", response_model=StatusOut)
|
@router.post("/support/read", response_model=StatusOut)
|
||||||
async def mark_support_read(
|
def mark_support_read(
|
||||||
username: Annotated[str, Depends(_require_username)],
|
username: Annotated[str, Depends(_require_username)],
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
) -> StatusOut:
|
) -> StatusOut:
|
||||||
|
"""Sync `def` (review M3) — см. `list_support_messages`."""
|
||||||
thread_id = storage.find_thread_id(db, username)
|
thread_id = storage.find_thread_id(db, username)
|
||||||
if thread_id is not None:
|
if thread_id is not None:
|
||||||
storage.mark_read(db, thread_id=thread_id)
|
storage.mark_read(db, thread_id=thread_id)
|
||||||
|
|
|
||||||
|
|
@ -97,19 +97,43 @@ class SlidingWindowLimiter:
|
||||||
self._window_s = window_s
|
self._window_s = window_s
|
||||||
self._hits: dict[str, deque[float]] = defaultdict(deque)
|
self._hits: dict[str, deque[float]] = defaultdict(deque)
|
||||||
|
|
||||||
def check(self, key: str) -> float | None:
|
def _prune(self, bucket: deque[float], now: float) -> None:
|
||||||
"""Регистрирует попытку под *key*. Возвращает None, если она уложилась в
|
|
||||||
лимит (и учтена), иначе — сколько секунд ждать до следующей попытки."""
|
|
||||||
now = time.monotonic()
|
|
||||||
bucket = self._hits[key]
|
|
||||||
cutoff = now - self._window_s
|
cutoff = now - self._window_s
|
||||||
while bucket and bucket[0] < cutoff:
|
while bucket and bucket[0] < cutoff:
|
||||||
bucket.popleft()
|
bucket.popleft()
|
||||||
|
|
||||||
|
def retry_after(self, key: str) -> float | None:
|
||||||
|
"""Non-destructive проверка: сколько секунд ждать, если *key* СЕЙЧАС за
|
||||||
|
лимитом, иначе None. НЕ регистрирует попытку — вызывающая сторона решает
|
||||||
|
сама, когда звать `record()` (обычно — только на успех действия, #tgsupport-web
|
||||||
|
review L3: неудачная попытка не должна съедать бюджет)."""
|
||||||
|
now = time.monotonic()
|
||||||
|
bucket = self._hits[key]
|
||||||
|
self._prune(bucket, now)
|
||||||
if len(bucket) >= self._limit:
|
if len(bucket) >= self._limit:
|
||||||
return self._window_s - (now - bucket[0])
|
return self._window_s - (now - bucket[0])
|
||||||
|
return None
|
||||||
|
|
||||||
|
def record(self, key: str) -> None:
|
||||||
|
"""Регистрирует одну успешную попытку под *key*."""
|
||||||
|
now = time.monotonic()
|
||||||
|
bucket = self._hits[key]
|
||||||
|
self._prune(bucket, now)
|
||||||
bucket.append(now)
|
bucket.append(now)
|
||||||
|
# Лёгкая защита от утечки памяти — чистим пустые корзины изредка (тот же
|
||||||
|
# паттерн, что RateLimitMiddleware.dispatch).
|
||||||
|
if len(self._hits) > 10000:
|
||||||
|
for k in [k for k, v in self._hits.items() if not v]:
|
||||||
|
del self._hits[k]
|
||||||
|
|
||||||
|
def check(self, key: str) -> float | None:
|
||||||
|
"""Комбинированная проверка+регистрация (peek+record за один вызов) —
|
||||||
|
для вызывающих, которым не нужно различать "попытка"/"успех" (см.
|
||||||
|
`retry_after`/`record` для раздельного варианта)."""
|
||||||
|
retry_after = self.retry_after(key)
|
||||||
|
if retry_after is not None:
|
||||||
|
return retry_after
|
||||||
|
self.record(key)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,23 @@
|
||||||
web_support_messages (direction='in') — этот модуль в этой ветке не участвует,
|
web_support_messages (direction='in') — этот модуль в этой ветке не участвует,
|
||||||
только в разборе ответа (B ниже).
|
только в разборе ответа (B ниже).
|
||||||
B) Оператор отвечает РЕПЛАЕМ в support-группе на зеркало клиента →
|
B) Оператор отвечает РЕПЛАЕМ в support-группе на зеркало клиента →
|
||||||
находим chat_id по topic_message_id → copyMessage ответа в личку клиента →
|
резолвим topic_message_id ОБЕ стороны (tg_support_messages И
|
||||||
запись (direction='out'). Если зеркало НЕ Telegram-клиента, а веб-чата
|
web_support_messages), скоупя к ТЕКУЩЕМУ TELEGRAM_SUPPORT_CHAT_ID
|
||||||
(data/sql/187_web_support_chat.sql) — доставка идёт НЕ в Telegram (у веб-
|
(#tgsupport-web review M1 — если группу когда-нибудь сменят/пересоздадут,
|
||||||
клиента нет личного чата с ботом), а записью direction='out' в
|
Telegram message_id стартует заново и может совпасть со старым числом из
|
||||||
web_support_messages (веб-фронт вычитывает её обычным polling'ом). Реплай
|
другой таблицы; без скоупинга это была бы ТИХАЯ доставка постороннему
|
||||||
не на зеркало (или не реплай вообще) — обычная болтовня в топике, тихий
|
клиенту). Совпадение НА ОБЕИХ сторонах одновременно — громкий отказ
|
||||||
игнор. Telegram 403 (клиент заблокировал бота) → is_blocked=true +
|
(`logger.error`, ничего не доставляем) вместо произвольного выбора одной из
|
||||||
уведомление в топике (только для Telegram-ветки — у веб-клиента нет
|
них. Иначе: chat_id найден → copyMessage ответа в личку клиента → запись
|
||||||
"заблокировал бота").
|
(direction='out'); thread_id найден (веб-зеркало) → доставка идёт НЕ в
|
||||||
|
Telegram (у веб-клиента нет личного чата с ботом), а записью direction='out'
|
||||||
|
в web_support_messages (веб-фронт вычитывает её обычным polling'ом); реплай
|
||||||
|
медиа-типом на веб-зеркало — веб-чат текстовый MVP, доставка целиком
|
||||||
|
отклоняется (не частично — фото с подписью НЕ превращается в "ответ = только
|
||||||
|
подпись"), оператор получает уведомление в топике (review M2). Реплай не на
|
||||||
|
зеркало (или не реплай вообще) — обычная болтовня в топике, тихий игнор.
|
||||||
|
Telegram 403 (клиент заблокировал бота) → is_blocked=true + уведомление в
|
||||||
|
топике (только для Telegram-ветки — у веб-клиента нет "заблокировал бота").
|
||||||
C) Дедуп: update_id <= сохранённого offset — skip. Offset сохраняется И
|
C) Дедуп: update_id <= сохранённого offset — skip. Offset сохраняется И
|
||||||
коммитится в той же транзакции, что и запись сообщения (см. `process_update`
|
коммитится в той же транзакции, что и запись сообщения (см. `process_update`
|
||||||
`finally`), после КАЖДОГО апдейта — рестарт воркера не переигрывает уже
|
`finally`), после КАЖДОГО апдейта — рестарт воркера не переигрывает уже
|
||||||
|
|
@ -79,6 +87,11 @@ SERVICE_UNAVAILABLE_TEXT = (
|
||||||
# tg_support_messages.kind): "text | photo | document | video | voice | other".
|
# tg_support_messages.kind): "text | photo | document | video | voice | other".
|
||||||
_KNOWN_KINDS = ("text", "photo", "document", "video", "voice")
|
_KNOWN_KINDS = ("text", "photo", "document", "video", "voice")
|
||||||
|
|
||||||
|
# #tgsupport-web review M2: реплай оператора медиа-типом (в т.ч. фото С ПОДПИСЬЮ)
|
||||||
|
# на веб-зеркало НЕ доставляется частично — веб-чат текстовый MVP, оператор
|
||||||
|
# получает это уведомление в топике вместо тихого игнора (иначе уверен, что ответил).
|
||||||
|
_WEB_UNSUPPORTED_MEDIA_REPLY_TEXT = "Веб-чат поддерживает только текст, сообщение не доставлено."
|
||||||
|
|
||||||
|
|
||||||
# ── Storage abstraction (testable без реальной БД) ──────────────────────────
|
# ── Storage abstraction (testable без реальной БД) ──────────────────────────
|
||||||
class BridgeStorage(Protocol):
|
class BridgeStorage(Protocol):
|
||||||
|
|
@ -115,13 +128,18 @@ class BridgeStorage(Protocol):
|
||||||
kind: str,
|
kind: str,
|
||||||
text_body: str | None,
|
text_body: str | None,
|
||||||
operator_tg_id: int | None,
|
operator_tg_id: int | None,
|
||||||
|
support_chat_id: int | None = None,
|
||||||
) -> int | None: ...
|
) -> int | None: ...
|
||||||
|
|
||||||
def find_chat_by_topic_message(self, topic_message_id: int) -> int | None: ...
|
def find_chat_by_topic_message(
|
||||||
|
self, topic_message_id: int, support_chat_id: int
|
||||||
|
) -> int | None: ...
|
||||||
|
|
||||||
def mark_blocked(self, chat_id: int) -> None: ...
|
def mark_blocked(self, chat_id: int) -> None: ...
|
||||||
|
|
||||||
def find_web_thread_by_topic_message(self, topic_message_id: int) -> int | None: ...
|
def find_web_thread_by_topic_message(
|
||||||
|
self, topic_message_id: int, support_chat_id: int
|
||||||
|
) -> int | None: ...
|
||||||
|
|
||||||
def record_web_out_message(
|
def record_web_out_message(
|
||||||
self, *, thread_id: int, text_body: str, operator_tg_id: int | None
|
self, *, thread_id: int, text_body: str, operator_tg_id: int | None
|
||||||
|
|
@ -243,17 +261,19 @@ class SqlBridgeStorage:
|
||||||
kind: str,
|
kind: str,
|
||||||
text_body: str | None,
|
text_body: str | None,
|
||||||
operator_tg_id: int | None,
|
operator_tg_id: int | None,
|
||||||
|
support_chat_id: int | None = None,
|
||||||
) -> int | None:
|
) -> int | None:
|
||||||
row = self._db.execute(
|
row = self._db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
INSERT INTO tg_support_messages
|
INSERT INTO tg_support_messages
|
||||||
(chat_id, direction, tg_message_id, topic_message_id, kind,
|
(chat_id, direction, tg_message_id, topic_message_id, kind,
|
||||||
text_body, operator_tg_id, created_at)
|
text_body, operator_tg_id, support_chat_id, created_at)
|
||||||
VALUES
|
VALUES
|
||||||
(CAST(:chat_id AS bigint), CAST(:direction AS text),
|
(CAST(:chat_id AS bigint), CAST(:direction AS text),
|
||||||
CAST(:tg_message_id AS bigint), CAST(:topic_message_id AS bigint),
|
CAST(:tg_message_id AS bigint), CAST(:topic_message_id AS bigint),
|
||||||
CAST(:kind AS text), :text_body, CAST(:operator_tg_id AS bigint), NOW())
|
CAST(:kind AS text), :text_body, CAST(:operator_tg_id AS bigint),
|
||||||
|
CAST(:support_chat_id AS bigint), NOW())
|
||||||
RETURNING id
|
RETURNING id
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
|
|
@ -265,11 +285,17 @@ class SqlBridgeStorage:
|
||||||
"kind": kind,
|
"kind": kind,
|
||||||
"text_body": text_body,
|
"text_body": text_body,
|
||||||
"operator_tg_id": operator_tg_id,
|
"operator_tg_id": operator_tg_id,
|
||||||
|
"support_chat_id": support_chat_id,
|
||||||
},
|
},
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return int(row[0]) if row is not None else None
|
return int(row[0]) if row is not None else None
|
||||||
|
|
||||||
def find_chat_by_topic_message(self, topic_message_id: int) -> int | None:
|
def find_chat_by_topic_message(self, topic_message_id: int, support_chat_id: int) -> int | None:
|
||||||
|
"""Скоупим к ТЕКУЩЕМУ support_chat_id (#tgsupport-web review M1) — строка
|
||||||
|
со ЧУЖИМ (не NULL, не текущим) support_chat_id — исторический артефакт
|
||||||
|
ротации support-группы, не валидный маршрут сегодня. NULL (строки до
|
||||||
|
миграции 188, если есть) — лениентный wildcard-матч (единственный
|
||||||
|
действовавший чат на тот момент)."""
|
||||||
row = self._db.execute(
|
row = self._db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
|
|
@ -277,11 +303,13 @@ class SqlBridgeStorage:
|
||||||
FROM tg_support_messages
|
FROM tg_support_messages
|
||||||
WHERE topic_message_id = CAST(:topic_message_id AS bigint)
|
WHERE topic_message_id = CAST(:topic_message_id AS bigint)
|
||||||
AND direction = 'in'
|
AND direction = 'in'
|
||||||
|
AND (support_chat_id = CAST(:support_chat_id AS bigint)
|
||||||
|
OR support_chat_id IS NULL)
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"topic_message_id": topic_message_id},
|
{"topic_message_id": topic_message_id, "support_chat_id": support_chat_id},
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return int(row[0]) if row is not None else None
|
return int(row[0]) if row is not None else None
|
||||||
|
|
||||||
|
|
@ -294,10 +322,14 @@ class SqlBridgeStorage:
|
||||||
{"chat_id": chat_id},
|
{"chat_id": chat_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
def find_web_thread_by_topic_message(self, topic_message_id: int) -> int | None:
|
def find_web_thread_by_topic_message(
|
||||||
|
self, topic_message_id: int, support_chat_id: int
|
||||||
|
) -> int | None:
|
||||||
"""Делегирует в `web_support_storage` (#tgsupport-web) — то же соединение/
|
"""Делегирует в `web_support_storage` (#tgsupport-web) — то же соединение/
|
||||||
транзакцию, что и tg-путь, коммитится вместе offset'ом в `process_update`."""
|
транзакцию, что и tg-путь, коммитится вместе offset'ом в `process_update`."""
|
||||||
return web_support_storage.find_thread_by_topic_message(self._db, topic_message_id)
|
return web_support_storage.find_thread_by_topic_message(
|
||||||
|
self._db, topic_message_id, support_chat_id
|
||||||
|
)
|
||||||
|
|
||||||
def record_web_out_message(
|
def record_web_out_message(
|
||||||
self, *, thread_id: int, text_body: str, operator_tg_id: int | None
|
self, *, thread_id: int, text_body: str, operator_tg_id: int | None
|
||||||
|
|
@ -400,6 +432,7 @@ async def _handle_private_message(
|
||||||
kind=_infer_kind(message),
|
kind=_infer_kind(message),
|
||||||
text_body=text_body or message.get("caption"),
|
text_body=text_body or message.get("caption"),
|
||||||
operator_tg_id=None,
|
operator_tg_id=None,
|
||||||
|
support_chat_id=settings.telegram_support_chat_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -416,11 +449,30 @@ async def _handle_group_reply(
|
||||||
if not isinstance(mirror_message_id, int):
|
if not isinstance(mirror_message_id, int):
|
||||||
return
|
return
|
||||||
|
|
||||||
target_chat_id = storage.find_chat_by_topic_message(mirror_message_id)
|
# #tgsupport-web review M1: резолвим ОБЕ стороны с текущим support_chat_id
|
||||||
|
# (НЕ short-circuit на первом найденном) — если topic_message_id совпал в
|
||||||
|
# ОБЕИХ таблицах одновременно, это значит инвариант "уникален в пределах
|
||||||
|
# текущей support-группы" нарушен (баг/ручная правка данных) — отказываем в
|
||||||
|
# доставке ГРОМКО, вместо того чтобы молча выбрать tg-путь и отправить ответ
|
||||||
|
# постороннему Telegram-клиенту (152-ФЗ misroute risk).
|
||||||
|
current_chat_id = settings.telegram_support_chat_id
|
||||||
|
target_chat_id = storage.find_chat_by_topic_message(mirror_message_id, current_chat_id)
|
||||||
|
web_thread_id = storage.find_web_thread_by_topic_message(mirror_message_id, current_chat_id)
|
||||||
|
|
||||||
|
if target_chat_id is not None and web_thread_id is not None:
|
||||||
|
logger.error(
|
||||||
|
"tgbot bridge: topic_message_id=%d резолвится ОДНОВРЕМЕННО в Telegram "
|
||||||
|
"(chat_id=%d) и веб-чат (thread_id=%d) под support_chat_id=%d — отказ в "
|
||||||
|
"доставке, требуется ручной разбор tg_support_messages/web_support_messages",
|
||||||
|
mirror_message_id,
|
||||||
|
target_chat_id,
|
||||||
|
web_thread_id,
|
||||||
|
current_chat_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
if target_chat_id is not None:
|
if target_chat_id is not None:
|
||||||
# Существующий Telegram-путь — НЕ ТРОНУТ, только обёрнут в explicit if
|
# Существующий Telegram-путь — НЕ ТРОНУТ.
|
||||||
# (раньше было `if target_chat_id is None: ...; return`, теперь после
|
|
||||||
# этой ветки идёт ещё веб-резолв, см. ниже).
|
|
||||||
message_id = message.get("message_id")
|
message_id = message.get("message_id")
|
||||||
if not isinstance(message_id, int):
|
if not isinstance(message_id, int):
|
||||||
return
|
return
|
||||||
|
|
@ -462,19 +514,32 @@ async def _handle_group_reply(
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# #tgsupport-web: не найдено среди tg_support_messages — пробуем веб-чат.
|
|
||||||
web_thread_id = storage.find_web_thread_by_topic_message(mirror_message_id)
|
|
||||||
if web_thread_id is not None:
|
if web_thread_id is not None:
|
||||||
text_body = message.get("text") or message.get("caption")
|
message_id = message.get("message_id")
|
||||||
if not text_body:
|
kind = _infer_kind(message)
|
||||||
# Веб-чат — текстовый MVP (web_support_messages.text_body NOT NULL,
|
if kind != "text":
|
||||||
# нет kind/file_id колонок как у tg_support_messages) — доставить
|
# #tgsupport-web review M2: НЕ доставляем частично (фото С ПОДПИСЬЮ
|
||||||
# фото/документ/voice некуда, фронт это не отрендерит.
|
# молча превратилось бы в "ответ = только текст подписи", клиент решил
|
||||||
|
# бы что это весь ответ) — отказ целиком + явное уведомление оператору
|
||||||
|
# в топике (тот же паттерн, что 403-уведомление выше), иначе оператор
|
||||||
|
# уверен, что ответ доставлен, хотя веб-чат не поддерживает медиа.
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"tgbot bridge: реплай на веб-зеркало (thread_id=%d) без текста "
|
"tgbot bridge: реплай на веб-зеркало (thread_id=%d) содержит %s, "
|
||||||
"(медиа?) — веб-чат текстовый, доставка невозможна, игнор",
|
"не текст — веб-чат поддерживает только текст, доставка отклонена",
|
||||||
web_thread_id,
|
web_thread_id,
|
||||||
|
kind,
|
||||||
)
|
)
|
||||||
|
await client.send_message(
|
||||||
|
chat_id=settings.telegram_support_chat_id,
|
||||||
|
text=_WEB_UNSUPPORTED_MEDIA_REPLY_TEXT,
|
||||||
|
message_thread_id=settings.telegram_support_topic_id or None,
|
||||||
|
reply_to_message_id=message_id if isinstance(message_id, int) else None,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
text_body = message.get("text")
|
||||||
|
if not text_body:
|
||||||
|
# Текстовый kind, но пустой text (защитный edge case) — нечего доставлять.
|
||||||
return
|
return
|
||||||
|
|
||||||
operator = message.get("from") or {}
|
operator = message.get("from") or {}
|
||||||
|
|
|
||||||
|
|
@ -238,12 +238,28 @@ class TelegramClient:
|
||||||
text: str,
|
text: str,
|
||||||
message_thread_id: int | None = None,
|
message_thread_id: int | None = None,
|
||||||
reply_to_message_id: int | None = None,
|
reply_to_message_id: int | None = None,
|
||||||
|
timeout: float | None = None,
|
||||||
|
max_retries: int | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""sendMessage — текстовое сообщение (заголовки, приветствия, уведомления об ошибке)."""
|
"""sendMessage — текстовое сообщение (заголовки, приветствия, уведомления об ошибке).
|
||||||
|
|
||||||
|
`timeout`/`max_retries` — по умолчанию наследуют воркерную политику
|
||||||
|
(`_DEFAULT_TIMEOUT_S`/`_DEFAULT_MAX_RETRIES`: на 429 спим Telegram-овский
|
||||||
|
`retry_after` — для группы это штатные 30-60с, на 5xx backoff до 30с).
|
||||||
|
Это ПРИЕМЛЕМО для `tgbot_main.py` (изолированный long-polling воркер), но
|
||||||
|
ФАТАЛЬНО для интерактивного HTTP-запроса (#tgsupport-web review H1) —
|
||||||
|
синхронный request/response путь не может легально висеть минуты. Вызывающая
|
||||||
|
сторона на interactive-пути ОБЯЗАНА передать узкий бюджет явно (см.
|
||||||
|
`app.api.v1.support.send_support_message`)."""
|
||||||
payload: dict[str, Any] = {"chat_id": chat_id, "text": text}
|
payload: dict[str, Any] = {"chat_id": chat_id, "text": text}
|
||||||
if message_thread_id:
|
if message_thread_id:
|
||||||
payload["message_thread_id"] = message_thread_id
|
payload["message_thread_id"] = message_thread_id
|
||||||
if reply_to_message_id:
|
if reply_to_message_id:
|
||||||
payload["reply_to_message_id"] = reply_to_message_id
|
payload["reply_to_message_id"] = reply_to_message_id
|
||||||
result = await self._request("sendMessage", payload)
|
kwargs: dict[str, Any] = {}
|
||||||
|
if timeout is not None:
|
||||||
|
kwargs["timeout"] = timeout
|
||||||
|
if max_retries is not None:
|
||||||
|
kwargs["max_retries"] = max_retries
|
||||||
|
result = await self._request("sendMessage", payload, **kwargs)
|
||||||
return result if isinstance(result, dict) else {}
|
return result if isinstance(result, dict) else {}
|
||||||
|
|
|
||||||
|
|
@ -62,19 +62,31 @@ def get_or_create_thread(db: Session, username: str) -> int:
|
||||||
|
|
||||||
|
|
||||||
def record_inbound(
|
def record_inbound(
|
||||||
db: Session, *, thread_id: int, text_body: str, topic_message_id: int | None
|
db: Session,
|
||||||
|
*,
|
||||||
|
thread_id: int,
|
||||||
|
text_body: str,
|
||||||
|
topic_message_id: int | None,
|
||||||
|
support_chat_id: int | None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Записывает сообщение пользователя сайта (direction='in'). `topic_message_id` —
|
"""Записывает сообщение пользователя сайта (direction='in'). `topic_message_id` —
|
||||||
id зеркала (sendMessage) в support-топике, ключ маршрутизации ответа оператора."""
|
id зеркала (sendMessage) в support-топике, ключ маршрутизации ответа оператора.
|
||||||
|
`support_chat_id` — TELEGRAM_SUPPORT_CHAT_ID В МОМЕНТ отправки (#tgsupport-web
|
||||||
|
review M1): скоупит будущий резолв `find_thread_by_topic_message` к ТЕКУЩЕЙ
|
||||||
|
support-группе — если группу когда-нибудь сменят/пересоздадут, Telegram
|
||||||
|
message_id стартует заново с 1 в новом чате и может совпасть с числом из
|
||||||
|
старого — без этого поля коллизия была бы ТИХОЙ (см. миграцию 187/188)."""
|
||||||
row = (
|
row = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
INSERT INTO web_support_messages
|
INSERT INTO web_support_messages
|
||||||
(thread_id, direction, text_body, topic_message_id, operator_tg_id, created_at)
|
(thread_id, direction, text_body, topic_message_id,
|
||||||
|
support_chat_id, operator_tg_id, created_at)
|
||||||
VALUES
|
VALUES
|
||||||
(CAST(:thread_id AS bigint), 'in', :text_body,
|
(CAST(:thread_id AS bigint), 'in', :text_body,
|
||||||
CAST(:topic_message_id AS bigint), NULL, NOW())
|
CAST(:topic_message_id AS bigint),
|
||||||
|
CAST(:support_chat_id AS bigint), NULL, NOW())
|
||||||
RETURNING id, direction, text_body, operator_tg_id, created_at
|
RETURNING id, direction, text_body, operator_tg_id, created_at
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
|
|
@ -82,6 +94,7 @@ def record_inbound(
|
||||||
"thread_id": thread_id,
|
"thread_id": thread_id,
|
||||||
"text_body": text_body,
|
"text_body": text_body,
|
||||||
"topic_message_id": topic_message_id,
|
"topic_message_id": topic_message_id,
|
||||||
|
"support_chat_id": support_chat_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
|
|
@ -90,9 +103,17 @@ def record_inbound(
|
||||||
return dict(row)
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
def find_thread_by_topic_message(db: Session, topic_message_id: int) -> int | None:
|
def find_thread_by_topic_message(
|
||||||
|
db: Session, topic_message_id: int, support_chat_id: int
|
||||||
|
) -> int | None:
|
||||||
"""Резолвит id зеркала (сообщения оператора reply_to) в thread_id — только
|
"""Резолвит id зеркала (сообщения оператора reply_to) в thread_id — только
|
||||||
среди direction='in' записей, зеркало-конвенция как в tg_support_messages (186)."""
|
среди direction='in' записей, зеркало-конвенция как в tg_support_messages (186).
|
||||||
|
|
||||||
|
Скоупим к ТЕКУЩЕМУ `support_chat_id` (#tgsupport-web review M1): строка со
|
||||||
|
ЧУЖИМ (не NULL и не текущим) support_chat_id — это исторический артефакт
|
||||||
|
ротации support-группы, НЕ валидный маршрут для сегодняшнего реплая. NULL
|
||||||
|
(легаси-строки до этой колонки, если такие есть) — лениентно матчатся как
|
||||||
|
"любой чат", т.к. до введения этого поля был ровно один действующий чат."""
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
|
|
@ -100,11 +121,12 @@ def find_thread_by_topic_message(db: Session, topic_message_id: int) -> int | No
|
||||||
FROM web_support_messages
|
FROM web_support_messages
|
||||||
WHERE topic_message_id = CAST(:topic_message_id AS bigint)
|
WHERE topic_message_id = CAST(:topic_message_id AS bigint)
|
||||||
AND direction = 'in'
|
AND direction = 'in'
|
||||||
|
AND (support_chat_id = CAST(:support_chat_id AS bigint) OR support_chat_id IS NULL)
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"topic_message_id": topic_message_id},
|
{"topic_message_id": topic_message_id, "support_chat_id": support_chat_id},
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return int(row[0]) if row is not None else None
|
return int(row[0]) if row is not None else None
|
||||||
|
|
||||||
|
|
@ -135,8 +157,17 @@ def record_outbound(
|
||||||
return int(row[0]) if row is not None else None
|
return int(row[0]) if row is not None else None
|
||||||
|
|
||||||
|
|
||||||
def list_messages(db: Session, *, thread_id: int, since_id: int) -> list[dict[str, Any]]:
|
def list_messages(
|
||||||
"""Сообщения треда с id > since_id, по возрастанию (обычный polling с фронта)."""
|
db: Session, *, thread_id: int, since_id: int, limit: int = 200
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Сообщения треда с id > since_id, по возрастанию (обычный polling с фронта).
|
||||||
|
|
||||||
|
`limit` (#tgsupport-web review M5): без него КАЖДОЕ монтирование виджета на
|
||||||
|
старом треде отдавало бы ВЕСЬ лог переписки. Берём последние `limit` (ORDER
|
||||||
|
BY id DESC + LIMIT), потом разворачиваем в хронологический порядок — так
|
||||||
|
incremental-polling (`since_id` = последний известный id, обычно единицы
|
||||||
|
новых строк) не страдает, а первый холодный load длинного треда получает
|
||||||
|
последние `limit`, а не самые старые."""
|
||||||
rows = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
|
|
@ -145,15 +176,16 @@ def list_messages(db: Session, *, thread_id: int, since_id: int) -> list[dict[st
|
||||||
FROM web_support_messages
|
FROM web_support_messages
|
||||||
WHERE thread_id = CAST(:thread_id AS bigint)
|
WHERE thread_id = CAST(:thread_id AS bigint)
|
||||||
AND id > CAST(:since_id AS bigint)
|
AND id > CAST(:since_id AS bigint)
|
||||||
ORDER BY id ASC
|
ORDER BY id DESC
|
||||||
|
LIMIT CAST(:limit AS integer)
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"thread_id": thread_id, "since_id": since_id},
|
{"thread_id": thread_id, "since_id": since_id, "limit": limit},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in reversed(rows)]
|
||||||
|
|
||||||
|
|
||||||
def count_unread(db: Session, *, thread_id: int) -> int:
|
def count_unread(db: Session, *, thread_id: int) -> int:
|
||||||
|
|
|
||||||
|
|
@ -35,15 +35,30 @@
|
||||||
-- - topic_message_id-маршрутизация (ключевой механизм моста) СОХРАНЕНА
|
-- - topic_message_id-маршрутизация (ключевой механизм моста) СОХРАНЕНА
|
||||||
-- 1-в-1 по конвенции 186: partial UNIQUE на topic_message_id,
|
-- 1-в-1 по конвенции 186: partial UNIQUE на topic_message_id,
|
||||||
-- заполняется только для direction='in', NULL для direction='out'.
|
-- заполняется только для direction='in', NULL для direction='out'.
|
||||||
-- Коллизий между web_support_messages.topic_message_id и
|
|
||||||
-- tg_support_messages.topic_message_id НЕ возникает: оба — Telegram
|
|
||||||
-- message_id ОДНОЙ и той же support-супергруппы, а Telegram message_id
|
|
||||||
-- в пределах одного чата монотонно возрастает и никогда не переиспользуется
|
|
||||||
-- — значит конкретное значение окажется ровно в одной из двух таблиц.
|
|
||||||
-- - bridge.py меняется МИНИМАЛЬНО: _handle_group_reply получает одну
|
-- - bridge.py меняется МИНИМАЛЬНО: _handle_group_reply получает одну
|
||||||
-- дополнительную ветку (пробуем tg-резолв, потом web-резолв, потом
|
-- дополнительную ветку (резолвит tg-путь И web-путь, потом
|
||||||
-- existing orphan-warning) — существующий Telegram-путь не трогается.
|
-- existing orphan-warning) — существующий Telegram-путь не трогается.
|
||||||
--
|
--
|
||||||
|
-- ⚠️ CROSS-TABLE КОЛЛИЗИЯ topic_message_id (review M1, зафиксировано ДО
|
||||||
|
-- первого прод-использования, пока обе таблицы пусты):
|
||||||
|
-- Инвариант "topic_message_id уникален между tg_support_messages и
|
||||||
|
-- web_support_messages" на самом деле звучит так: "уникален, ПОКА
|
||||||
|
-- TELEGRAM_SUPPORT_CHAT_ID не менялся". Это OPS-инвариант, а НЕ DB-инвариант —
|
||||||
|
-- ничем не гарантирован. Смена/пересоздание support-группы обнуляет счётчик
|
||||||
|
-- Telegram message_id в новом чате; когда он дорастёт до диапазона,
|
||||||
|
-- использованного старым чатом, — number, ранее занятый ОДНОЙ таблицей,
|
||||||
|
-- может совпасть с числом, занятым ДРУГОЙ. Внутри одной таблицы partial
|
||||||
|
-- UNIQUE превращает такую коллизию в громкий отказ INSERT — это ок. МЕЖДУ
|
||||||
|
-- таблицами constraint'а нет: без доп. скоупинга бот молча доставил бы ответ
|
||||||
|
-- оператора НЕ ТОМУ клиенту (152-ФЗ-инцидент, происходящий тихо).
|
||||||
|
-- Фикс: колонка `support_chat_id` на web_support_messages (симметричная
|
||||||
|
-- колонка для УЖЕ применённой tg_support_messages — отдельная миграция
|
||||||
|
-- 188_tg_support_chat_id_scope.sql, эту таблицу нельзя трогать здесь, она
|
||||||
|
-- уже применена/задеплоена как часть 186). Резолв (bridge.py) матчит ПАРУ
|
||||||
|
-- (support_chat_id, topic_message_id), а не topic_message_id в одиночку;
|
||||||
|
-- NULL (легаси-строки без этой колонки) — лениентный wildcard, т.к. на тот
|
||||||
|
-- момент действовал ровно один чат.
|
||||||
|
--
|
||||||
-- ЧТО:
|
-- ЧТО:
|
||||||
-- - web_support_threads — один тред на username (сайт = 1 логин = 1 линия
|
-- - web_support_threads — один тред на username (сайт = 1 логин = 1 линия
|
||||||
-- переписки с поддержкой, без под-тредов).
|
-- переписки с поддержкой, без под-тредов).
|
||||||
|
|
@ -53,11 +68,16 @@
|
||||||
-- 152-ФЗ:
|
-- 152-ФЗ:
|
||||||
-- Переписка (text_body) — ПДн (может содержать любые данные, которые юзер
|
-- Переписка (text_body) — ПДн (может содержать любые данные, которые юзер
|
||||||
-- решит написать). ON DELETE CASCADE от web_support_threads делает erasure
|
-- решит написать). ON DELETE CASCADE от web_support_threads делает erasure
|
||||||
-- одной операцией: DELETE FROM web_support_threads WHERE username = :u.
|
-- ОДНОЙ операцией (DELETE FROM web_support_threads WHERE username = :u) ДЛЯ
|
||||||
|
-- КОПИИ В ЭТОЙ БД. Копия того же текста уже ушла в Telegram-топик (sendMessage
|
||||||
|
-- зеркало) и живёт ТАМ вне зоны действия этого DELETE — реальное "право на
|
||||||
|
-- забвение" по всей цепочке требует ОТДЕЛЬНОЙ процедуры (удаление сообщений в
|
||||||
|
-- Telegram-супергруппе через Bot API deleteMessage, вне scope этой миграции).
|
||||||
|
-- Не ссылаться на этот комментарий как на доказательство полного erasure.
|
||||||
--
|
--
|
||||||
-- IDEMPOTENCY: CREATE TABLE/INDEX IF NOT EXISTS — безопасный re-run.
|
-- IDEMPOTENCY: CREATE TABLE/INDEX IF NOT EXISTS — безопасный re-run.
|
||||||
-- Зависимости: нет (новые standalone таблицы, никакие существующие
|
-- Зависимости: нет (новые standalone таблицы, никакие существующие
|
||||||
-- tg_support_*/иные таблицы не трогаются).
|
-- tg_support_*/иные таблицы не трогаются — см. 188 для ALTER на tg_support_messages).
|
||||||
|
|
||||||
BEGIN;
|
BEGIN;
|
||||||
|
|
||||||
|
|
@ -80,14 +100,16 @@ CREATE TABLE IF NOT EXISTS web_support_messages (
|
||||||
direction text NOT NULL CHECK (direction IN ('in', 'out')),
|
direction text NOT NULL CHECK (direction IN ('in', 'out')),
|
||||||
text_body text NOT NULL CHECK (char_length(btrim(text_body)) > 0),
|
text_body text NOT NULL CHECK (char_length(btrim(text_body)) > 0),
|
||||||
topic_message_id bigint,
|
topic_message_id bigint,
|
||||||
|
support_chat_id bigint,
|
||||||
operator_tg_id bigint,
|
operator_tg_id bigint,
|
||||||
created_at timestamptz NOT NULL DEFAULT now()
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
COMMENT ON TABLE web_support_messages IS '152-ФЗ: полный лог веб-чата поддержки (ПДн — содержимое сообщений). Каскадно удаляется вместе с web_support_threads по username.';
|
COMMENT ON TABLE web_support_messages IS '152-ФЗ: полный лог веб-чата поддержки (ПДн — содержимое сообщений; удаление подчищает КОПИЮ В ЭТОЙ БД, не Telegram-топик — см. блок 152-ФЗ выше). Каскадно удаляется вместе с web_support_threads по username.';
|
||||||
COMMENT ON COLUMN web_support_messages.direction IS '''in'' — сообщение от пользователя сайта; ''out'' — ответ оператора (доставлен через реплай в Telegram-топике, см. bridge.py _handle_group_reply).';
|
COMMENT ON COLUMN web_support_messages.direction IS '''in'' — сообщение от пользователя сайта; ''out'' — ответ оператора (доставлен через реплай в Telegram-топике, см. bridge.py _handle_group_reply).';
|
||||||
COMMENT ON COLUMN web_support_messages.text_body IS 'Текст сообщения. Веб-чат — текстовый MVP, медиа не поддерживается (в отличие от tg_support_messages.kind).';
|
COMMENT ON COLUMN web_support_messages.text_body IS 'Текст сообщения. Веб-чат — текстовый MVP, медиа не поддерживается (в отличие от tg_support_messages.kind).';
|
||||||
COMMENT ON COLUMN web_support_messages.topic_message_id IS 'id зеркала (sendMessage) в support-топике — ключ маршрутизации ответа, только для direction=''in''. NULL для ''out'' (конвенция 186: маршрутизирующий ключ живёт исключительно на inbound-записи).';
|
COMMENT ON COLUMN web_support_messages.topic_message_id IS 'id зеркала (sendMessage) в support-топике — ключ маршрутизации ответа, только для direction=''in''. NULL для ''out'' (конвенция 186: маршрутизирующий ключ живёт исключительно на inbound-записи).';
|
||||||
|
COMMENT ON COLUMN web_support_messages.support_chat_id IS 'TELEGRAM_SUPPORT_CHAT_ID в момент отправки — скоупит резолв topic_message_id к ТЕКУЩЕЙ support-группе (review M1: без этого поля ротация группы даёт тихую cross-table коллизию, см. блок выше). NULL — лениентный wildcard для строк без этого поля.';
|
||||||
COMMENT ON COLUMN web_support_messages.operator_tg_id IS 'Telegram user id оператора, ответившего в топике; заполняется только для direction=''out''.';
|
COMMENT ON COLUMN web_support_messages.operator_tg_id IS 'Telegram user id оператора, ответившего в топике; заполняется только для direction=''out''.';
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS web_support_messages_topic_message_id_uq
|
CREATE UNIQUE INDEX IF NOT EXISTS web_support_messages_topic_message_id_uq
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
-- 188_tg_support_chat_id_scope.sql
|
||||||
|
-- Симметричная колонка для web_support_messages.support_chat_id (см.
|
||||||
|
-- data/sql/187_web_support_chat.sql — полный разбор проблемы в блоке "CROSS-TABLE
|
||||||
|
-- КОЛЛИЗИЯ topic_message_id" там же).
|
||||||
|
--
|
||||||
|
-- ПОЧЕМУ ОТДЕЛЬНАЯ МИГРАЦИЯ, А НЕ ПРАВКА 186:
|
||||||
|
-- tg_support_messages создана в data/sql/186_tg_support.sql — миграция, которая
|
||||||
|
-- к моменту написания этого файла уже смержена в main отдельным PR (#2526) и,
|
||||||
|
-- по конвенции проекта (deploy-tradein.yml применяет каждый data/sql/*.sql РОВНО
|
||||||
|
-- ОДИН РАЗ по bare filename через _schema_migrations), скорее всего уже
|
||||||
|
-- применена на проде. Редактирование СОДЕРЖИМОГО уже применённого файла НЕ
|
||||||
|
-- долетает до прода повторным прогоном — прод просто пропустит файл с тем же
|
||||||
|
-- именем. Единственный корректный способ добавить колонку в уже существующую
|
||||||
|
-- таблицу — новый ALTER-файл.
|
||||||
|
--
|
||||||
|
-- ЧТО: tg_support_messages.support_chat_id bigint (nullable) — TELEGRAM_SUPPORT_
|
||||||
|
-- CHAT_ID в момент записи 'in'-сообщения. bridge.py.find_chat_by_topic_message
|
||||||
|
-- матчит (support_chat_id, topic_message_id) вместо topic_message_id в одиночку;
|
||||||
|
-- NULL (все строки ДО этой миграции) — лениентный wildcard-матч, т.к. до
|
||||||
|
-- появления этой колонки действовал ровно один support-чат за раз.
|
||||||
|
--
|
||||||
|
-- Бэкфилл существующих строк текущим TELEGRAM_SUPPORT_CHAT_ID НЕ делаем: значение
|
||||||
|
-- живёт в Python `settings`/env, разное на каждом окружении (dev/staging/prod), а
|
||||||
|
-- plain-SQL миграция не имеет доступа к процессным env vars — хардкодить
|
||||||
|
-- конкретный chat_id в SQL-файл было бы хрупко и окружение-специфично. NULL
|
||||||
|
-- (wildcard) для существующих строк — безопасный дефолт: они писались, когда
|
||||||
|
-- support-чат был ровно один, коллизии из-за смены чата у НИХ по определению
|
||||||
|
-- невозможны (см. 187 — только смена чата ПОСЛЕ появления этой колонки создаёт
|
||||||
|
-- сценарий, который она защищает).
|
||||||
|
--
|
||||||
|
-- IDEMPOTENCY: ADD COLUMN IF NOT EXISTS — безопасный re-run. Не трогает
|
||||||
|
-- существующие данные/constraints tg_support_messages.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE tg_support_messages ADD COLUMN IF NOT EXISTS support_chat_id bigint;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN tg_support_messages.support_chat_id IS 'TELEGRAM_SUPPORT_CHAT_ID в момент записи ''in''-сообщения — скоупит резолв topic_message_id к ТЕКУЩЕЙ support-группе (review M1, см. data/sql/187_web_support_chat.sql). NULL — строки до этой колонки (лениентный wildcard-матч).';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -176,4 +176,3 @@
|
||||||
169_osm_poi_ekb_local.sql
|
169_osm_poi_ekb_local.sql
|
||||||
170_scrape_schedules_seed_osm_poi_ekb_refresh.sql
|
170_scrape_schedules_seed_osm_poi_ekb_refresh.sql
|
||||||
172_trade_in_leads.sql
|
172_trade_in_leads.sql
|
||||||
187_web_support_chat.sql
|
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,14 @@ Coverage (per task spec + review follow-up):
|
||||||
после истечения окна — #6 review)
|
после истечения окна — #6 review)
|
||||||
- реплай оператора → user (доставка ответа клиенту + запись direction='out')
|
- реплай оператора → user (доставка ответа клиенту + запись direction='out')
|
||||||
- реплай оператора → веб-чат (#tgsupport-web): зеркало веб-сообщения резолвится
|
- реплай оператора → веб-чат (#tgsupport-web): зеркало веб-сообщения резолвится
|
||||||
в web-тред, ответ пишется direction='out' БЕЗ Telegram-доставки; медиа-реплай
|
в web-тред (скоуп по (topic_message_id, support_chat_id) — review M1), ответ
|
||||||
на веб-зеркало (нет text/caption) — тихий игнор (веб-чат текстовый MVP);
|
пишется direction='out' БЕЗ Telegram-доставки; медиа-реплай (в т.ч. фото С
|
||||||
tg-резолв имеет приоритет над веб-резолвом при (искусственной) коллизии
|
ПОДПИСЬЮ) на веб-зеркало — отказ ЦЕЛИКОМ + уведомление оператору в топике
|
||||||
|
(review M2, никакой частичной доставки одной подписи); зеркало от ЧУЖОГО/
|
||||||
|
устаревшего support_chat_id — не матчится (ротация группы); NULL
|
||||||
|
support_chat_id (легаси) — wildcard-матч; совпадение ОБЕИХ сторон
|
||||||
|
одновременно (tg И web) — громкий отказ (logger.error), а не молчаливый
|
||||||
|
выбор tg-пути (152-ФЗ misroute risk)
|
||||||
- реплай не на зеркало (или не реплай вообще) — тихий игнор, не мусорим в чат;
|
- реплай не на зеркало (или не реплай вообще) — тихий игнор, не мусорим в чат;
|
||||||
реплай на СООБЩЕНИЕ БОТА без записи в БД — WARNING про осиротевшее зеркало
|
реплай на СООБЩЕНИЕ БОТА без записи в БД — WARNING про осиротевшее зеркало
|
||||||
(#4 review)
|
(#4 review)
|
||||||
|
|
@ -69,9 +74,11 @@ class FakeBridgeStorage:
|
||||||
self._next_id = 1
|
self._next_id = 1
|
||||||
self.clock_s: float = 0.0
|
self.clock_s: float = 0.0
|
||||||
self.fail_next_record_message = False
|
self.fail_next_record_message = False
|
||||||
# #tgsupport-web: web_support_messages-эквивалент (topic_message_id ->
|
# #tgsupport-web: web_support_messages-эквивалент, topic_message_id ->
|
||||||
# thread_id) + журнал outbound-записей, записанных через реплай оператора.
|
# (thread_id, support_chat_id) — второй элемент моделирует колонку
|
||||||
self.web_topic_to_thread: dict[int, int] = {}
|
# web_support_messages.support_chat_id (review M1); None = легаси wildcard.
|
||||||
|
# + журнал outbound-записей, записанных через реплай оператора.
|
||||||
|
self.web_topic_to_thread: dict[int, tuple[int, int | None]] = {}
|
||||||
self.web_out_messages: list[dict[str, Any]] = []
|
self.web_out_messages: list[dict[str, Any]] = []
|
||||||
|
|
||||||
def get_offset(self) -> int:
|
def get_offset(self) -> int:
|
||||||
|
|
@ -120,6 +127,7 @@ class FakeBridgeStorage:
|
||||||
kind: str,
|
kind: str,
|
||||||
text_body: str | None,
|
text_body: str | None,
|
||||||
operator_tg_id: int | None,
|
operator_tg_id: int | None,
|
||||||
|
support_chat_id: int | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
if self.fail_next_record_message:
|
if self.fail_next_record_message:
|
||||||
self.fail_next_record_message = False
|
self.fail_next_record_message = False
|
||||||
|
|
@ -136,14 +144,21 @@ class FakeBridgeStorage:
|
||||||
"kind": kind,
|
"kind": kind,
|
||||||
"text_body": text_body,
|
"text_body": text_body,
|
||||||
"operator_tg_id": operator_tg_id,
|
"operator_tg_id": operator_tg_id,
|
||||||
|
"support_chat_id": support_chat_id,
|
||||||
"recorded_at_s": self.clock_s,
|
"recorded_at_s": self.clock_s,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return row_id
|
return row_id
|
||||||
|
|
||||||
def find_chat_by_topic_message(self, topic_message_id: int) -> int | None:
|
def find_chat_by_topic_message(self, topic_message_id: int, support_chat_id: int) -> int | None:
|
||||||
|
"""support_chat_id-скоуп (review M1): запись со ЧУЖИМ (не None, не текущим)
|
||||||
|
support_chat_id не матчится — None (легаси/дефолт) матчится всегда."""
|
||||||
for m in reversed(self.messages):
|
for m in reversed(self.messages):
|
||||||
if m["direction"] == "in" and m["topic_message_id"] == topic_message_id:
|
if m["direction"] != "in" or m["topic_message_id"] != topic_message_id:
|
||||||
|
continue
|
||||||
|
entry_chat_id = m.get("support_chat_id")
|
||||||
|
if entry_chat_id is not None and entry_chat_id != support_chat_id:
|
||||||
|
continue
|
||||||
return m["chat_id"]
|
return m["chat_id"]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -151,8 +166,17 @@ class FakeBridgeStorage:
|
||||||
self.blocked.add(chat_id)
|
self.blocked.add(chat_id)
|
||||||
|
|
||||||
# ── #tgsupport-web ────────────────────────────────────────────────────
|
# ── #tgsupport-web ────────────────────────────────────────────────────
|
||||||
def find_web_thread_by_topic_message(self, topic_message_id: int) -> int | None:
|
def find_web_thread_by_topic_message(
|
||||||
return self.web_topic_to_thread.get(topic_message_id)
|
self, topic_message_id: int, support_chat_id: int
|
||||||
|
) -> int | None:
|
||||||
|
"""Тот же support_chat_id-скоуп, что и `find_chat_by_topic_message` (review M1)."""
|
||||||
|
entry = self.web_topic_to_thread.get(topic_message_id)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
thread_id, entry_chat_id = entry
|
||||||
|
if entry_chat_id is not None and entry_chat_id != support_chat_id:
|
||||||
|
return None
|
||||||
|
return thread_id
|
||||||
|
|
||||||
def record_web_out_message(
|
def record_web_out_message(
|
||||||
self, *, thread_id: int, text_body: str, operator_tg_id: int | None
|
self, *, thread_id: int, text_body: str, operator_tg_id: int | None
|
||||||
|
|
@ -561,7 +585,8 @@ async def test_group_reply_to_web_mirror_records_outbound_web_message() -> None:
|
||||||
calls: list[tuple[str, dict[str, Any]]] = []
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
client = _make_client({}, calls)
|
client = _make_client({}, calls)
|
||||||
storage = FakeBridgeStorage()
|
storage = FakeBridgeStorage()
|
||||||
storage.web_topic_to_thread[300] = 42 # topic_message_id=300 -> thread_id=42
|
# topic_message_id=300 -> thread_id=42, под ТЕКУЩИМ support_chat_id.
|
||||||
|
storage.web_topic_to_thread[300] = (42, SUPPORT_CHAT_ID)
|
||||||
|
|
||||||
update = {
|
update = {
|
||||||
"update_id": 60,
|
"update_id": 60,
|
||||||
|
|
@ -581,33 +606,107 @@ async def test_group_reply_to_web_mirror_records_outbound_web_message() -> None:
|
||||||
assert storage.get_offset() == 60
|
assert storage.get_offset() == 60
|
||||||
|
|
||||||
|
|
||||||
async def test_group_reply_to_web_mirror_without_text_is_ignored(
|
async def test_group_reply_to_web_mirror_with_null_support_chat_id_matches_current_chat() -> None:
|
||||||
caplog: pytest.LogCaptureFixture,
|
"""Легаси-строка (до 187/188, support_chat_id=None) — лениентный wildcard,
|
||||||
) -> None:
|
матчится под ЛЮБЫМ текущим support_chat_id (review M1)."""
|
||||||
"""Веб-чат — текстовый MVP: реплай медиа-типом (нет text/caption) на веб-зеркало
|
|
||||||
не может быть доставлен — тихий (WARNING, не error) игнор, ничего не пишем."""
|
|
||||||
calls: list[tuple[str, dict[str, Any]]] = []
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
client = _make_client({}, calls)
|
client = _make_client({}, calls)
|
||||||
storage = FakeBridgeStorage()
|
storage = FakeBridgeStorage()
|
||||||
storage.web_topic_to_thread[301] = 43
|
storage.web_topic_to_thread[305] = (46, None)
|
||||||
|
|
||||||
message = _group_reply_message(reply_to_message_id=301)
|
update = {
|
||||||
|
"update_id": 63,
|
||||||
|
"message": _group_reply_message(reply_to_message_id=305, text="Ответ по легаси-зеркалу"),
|
||||||
|
}
|
||||||
|
await bridge.process_update(update, client, storage)
|
||||||
|
|
||||||
|
assert len(storage.web_out_messages) == 1
|
||||||
|
assert storage.web_out_messages[0]["thread_id"] == 46
|
||||||
|
|
||||||
|
|
||||||
|
async def test_group_reply_to_web_mirror_from_stale_support_chat_is_not_matched() -> None:
|
||||||
|
"""#tgsupport-web review M1: зеркало, записанное под ДРУГИМ (не текущим,
|
||||||
|
не None) support_chat_id — исторический артефакт ротации группы, НЕ валидный
|
||||||
|
маршрут сегодня. Не матчится → падает в orphan-check (не-bot реплай — тихий
|
||||||
|
игнор, никакой доставки в чужой/устаревший тред)."""
|
||||||
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
client = _make_client({}, calls)
|
||||||
|
storage = FakeBridgeStorage()
|
||||||
|
stale_chat_id = -999999999999
|
||||||
|
storage.web_topic_to_thread[306] = (47, stale_chat_id)
|
||||||
|
|
||||||
|
update = {
|
||||||
|
"update_id": 64,
|
||||||
|
"message": _group_reply_message(reply_to_message_id=306),
|
||||||
|
}
|
||||||
|
await bridge.process_update(update, client, storage)
|
||||||
|
|
||||||
|
assert calls == []
|
||||||
|
assert storage.web_out_messages == [] # НЕ доставлено в устаревший тред
|
||||||
|
|
||||||
|
|
||||||
|
async def test_group_reply_to_web_mirror_without_text_is_refused_with_operator_notice(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Веб-чат — текстовый MVP: реплай медиа-типом (нет text/caption) на веб-зеркало
|
||||||
|
не может быть доставлен — WARNING в лог И явное уведомление оператору в топике
|
||||||
|
(review M2: раньше был тихий игнор, оператор был уверен что ответил)."""
|
||||||
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
client = _make_client({}, calls)
|
||||||
|
storage = FakeBridgeStorage()
|
||||||
|
storage.web_topic_to_thread[301] = (43, SUPPORT_CHAT_ID)
|
||||||
|
|
||||||
|
message = _group_reply_message(reply_to_message_id=301, message_id=201)
|
||||||
del message["text"] # медиа-реплай без текста/caption
|
del message["text"] # медиа-реплай без текста/caption
|
||||||
|
message["voice"] = {"file_id": "x"}
|
||||||
update = {"update_id": 61, "message": message}
|
update = {"update_id": 61, "message": message}
|
||||||
|
|
||||||
with caplog.at_level(logging.WARNING, logger="app.services.tgbot.bridge"):
|
with caplog.at_level(logging.WARNING, logger="app.services.tgbot.bridge"):
|
||||||
await bridge.process_update(update, client, storage)
|
await bridge.process_update(update, client, storage)
|
||||||
|
|
||||||
assert calls == []
|
|
||||||
assert storage.web_out_messages == []
|
assert storage.web_out_messages == []
|
||||||
assert "текстовый" in caplog.text
|
assert "не текст" in caplog.text
|
||||||
assert storage.get_offset() == 61
|
assert storage.get_offset() == 61
|
||||||
|
|
||||||
|
methods = [m for m, _ in calls]
|
||||||
|
assert methods == ["sendMessage"]
|
||||||
|
notice_call = calls[0][1]
|
||||||
|
assert notice_call["chat_id"] == SUPPORT_CHAT_ID
|
||||||
|
assert notice_call["text"] == bridge._WEB_UNSUPPORTED_MEDIA_REPLY_TEXT
|
||||||
|
assert notice_call["reply_to_message_id"] == 201
|
||||||
|
|
||||||
async def test_group_reply_prefers_tg_thread_when_both_would_match() -> None:
|
|
||||||
"""Приоритет резолва — tg СНАЧАЛА: если topic_message_id найден среди
|
async def test_group_reply_to_web_mirror_with_photo_and_caption_is_refused_not_partial() -> None:
|
||||||
tg_support_messages, веб-резолв даже не вызывается (существующий Telegram-путь
|
"""Фото С ПОДПИСЬЮ на веб-зеркало — НЕ доставляем только подпись молча
|
||||||
работает как раньше, не деградирует из-за новой ветки)."""
|
(клиент решил бы, что подпись — весь ответ): отказ целиком, как и без caption
|
||||||
|
(review M2)."""
|
||||||
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
client = _make_client({}, calls)
|
||||||
|
storage = FakeBridgeStorage()
|
||||||
|
storage.web_topic_to_thread[302] = (44, SUPPORT_CHAT_ID)
|
||||||
|
|
||||||
|
message = _group_reply_message(reply_to_message_id=302, message_id=202)
|
||||||
|
del message["text"]
|
||||||
|
message["photo"] = [{"file_id": "x"}]
|
||||||
|
message["caption"] = "Смотрите скриншот"
|
||||||
|
update = {"update_id": 65, "message": message}
|
||||||
|
|
||||||
|
await bridge.process_update(update, client, storage)
|
||||||
|
|
||||||
|
assert storage.web_out_messages == [] # подпись НЕ доставлена как "весь ответ"
|
||||||
|
methods = [m for m, _ in calls]
|
||||||
|
assert methods == ["sendMessage"]
|
||||||
|
assert calls[0][1]["text"] == bridge._WEB_UNSUPPORTED_MEDIA_REPLY_TEXT
|
||||||
|
|
||||||
|
|
||||||
|
async def test_group_reply_refuses_delivery_when_both_tg_and_web_match(
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""#tgsupport-web review M1: если topic_message_id одновременно резолвится и в
|
||||||
|
tg_support_messages, И в web_support_messages (под ОДНИМ и тем же
|
||||||
|
support_chat_id — целостность нарушена) — ГРОМКИЙ отказ (logger.error), НИКАКОЙ
|
||||||
|
доставки ни в Telegram-личку, ни в веб-тред. Раньше tg-путь выбирался молча —
|
||||||
|
misroute постороннему Telegram-клиенту (152-ФЗ risk)."""
|
||||||
calls: list[tuple[str, dict[str, Any]]] = []
|
calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
client = _make_client({"copyMessage": {"message_id": 999}}, calls)
|
client = _make_client({"copyMessage": {"message_id": 999}}, calls)
|
||||||
storage = FakeBridgeStorage()
|
storage = FakeBridgeStorage()
|
||||||
|
|
@ -619,24 +718,22 @@ async def test_group_reply_prefers_tg_thread_when_both_would_match() -> None:
|
||||||
kind="text",
|
kind="text",
|
||||||
text_body="вопрос клиента",
|
text_body="вопрос клиента",
|
||||||
operator_tg_id=None,
|
operator_tg_id=None,
|
||||||
|
support_chat_id=SUPPORT_CHAT_ID,
|
||||||
)
|
)
|
||||||
# Тот же topic_message_id "случайно" тоже был бы в web-мапе — не должен
|
storage.web_topic_to_thread[400] = (99, SUPPORT_CHAT_ID)
|
||||||
# переопределять tg-резолв (defensive, в реальности Telegram message_id не
|
|
||||||
# повторяется в пределах чата).
|
|
||||||
storage.web_topic_to_thread[400] = 99
|
|
||||||
|
|
||||||
update = {
|
update = {
|
||||||
"update_id": 62,
|
"update_id": 62,
|
||||||
"message": _group_reply_message(reply_to_message_id=400),
|
"message": _group_reply_message(reply_to_message_id=400),
|
||||||
}
|
}
|
||||||
|
with caplog.at_level(logging.ERROR, logger="app.services.tgbot.bridge"):
|
||||||
await bridge.process_update(update, client, storage)
|
await bridge.process_update(update, client, storage)
|
||||||
|
|
||||||
methods = [m for m, _ in calls]
|
assert calls == [] # ничего не доставлено НИ В ОДНУ сторону
|
||||||
assert methods == ["copyMessage"]
|
|
||||||
assert storage.web_out_messages == []
|
assert storage.web_out_messages == []
|
||||||
out_rec = storage.messages[-1]
|
assert len(storage.messages) == 1 # только исходное 'in', никакого 'out'
|
||||||
assert out_rec["direction"] == "out"
|
assert "ОДНОВРЕМЕННО" in caplog.text
|
||||||
assert out_rec["chat_id"] == 555
|
assert storage.get_offset() == 62
|
||||||
|
|
||||||
|
|
||||||
# ── C) дедуп ──────────────────────────────────────────────────────────────────
|
# ── C) дедуп ──────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ from fastapi import FastAPI
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.core import config
|
from app.core import config
|
||||||
from app.core.ratelimit import RateLimitMiddleware
|
from app.core.ratelimit import RateLimitMiddleware, SlidingWindowLimiter
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -112,3 +112,58 @@ def test_anonymous_key_from_rightmost_xff(client):
|
||||||
client.get("/api/v1/ping", headers={"X-Forwarded-For": "8.8.8.8, 2.2.2.2"}).status_code
|
client.get("/api/v1/ping", headers={"X-Forwarded-For": "8.8.8.8, 2.2.2.2"}).status_code
|
||||||
== 200
|
== 200
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── SlidingWindowLimiter (#tgsupport-web) — reusable narrower per-feature limit ──
|
||||||
|
|
||||||
|
|
||||||
|
def test_sliding_window_limiter_retry_after_does_not_record():
|
||||||
|
"""`.retry_after()` — non-destructive peek: не расходует бюджет сам по себе
|
||||||
|
(review L3 — вызывающая сторона решает, когда `.record()`)."""
|
||||||
|
limiter = SlidingWindowLimiter(limit=1, window_s=60.0)
|
||||||
|
assert limiter.retry_after("alice") is None
|
||||||
|
# Повторный peek БЕЗ record() — бюджет не тронут, всё ещё None.
|
||||||
|
assert limiter.retry_after("alice") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_sliding_window_limiter_record_then_retry_after_blocks():
|
||||||
|
limiter = SlidingWindowLimiter(limit=1, window_s=60.0)
|
||||||
|
assert limiter.retry_after("alice") is None
|
||||||
|
limiter.record("alice")
|
||||||
|
retry_after = limiter.retry_after("alice")
|
||||||
|
assert retry_after is not None
|
||||||
|
assert retry_after > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_sliding_window_limiter_check_combines_peek_and_record():
|
||||||
|
"""`.check()` — комбинированный (обратной совместимости ради) peek+record."""
|
||||||
|
limiter = SlidingWindowLimiter(limit=1, window_s=60.0)
|
||||||
|
assert limiter.check("alice") is None # первый — проходит и сразу учитывается
|
||||||
|
assert limiter.check("alice") is not None # второй — уже за лимитом
|
||||||
|
|
||||||
|
|
||||||
|
def test_sliding_window_limiter_per_key_isolation():
|
||||||
|
limiter = SlidingWindowLimiter(limit=1, window_s=60.0)
|
||||||
|
limiter.record("alice")
|
||||||
|
assert limiter.retry_after("alice") is not None
|
||||||
|
assert limiter.retry_after("bob") is None # свой ключ — не задет alice
|
||||||
|
|
||||||
|
|
||||||
|
def test_sliding_window_limiter_prunes_empty_buckets_past_threshold():
|
||||||
|
"""review L2: пустые корзины чистятся при накоплении >10000 ключей (тот же
|
||||||
|
паттерн, что `RateLimitMiddleware.dispatch`) — не бесконечная утечка памяти.
|
||||||
|
|
||||||
|
`retry_after()`-peek на новом ключе создаёт ПУСТУЮ корзину как побочный эффект
|
||||||
|
`defaultdict` (даже если вызывающая сторона так и не позвала `.record()` —
|
||||||
|
напр. запрос отклонён по другой причине выше по стеку). Это главный источник
|
||||||
|
"мусорных" пустых корзин, которые cleanup обязан подбирать.
|
||||||
|
"""
|
||||||
|
limiter = SlidingWindowLimiter(limit=1000, window_s=60.0)
|
||||||
|
for i in range(10001):
|
||||||
|
limiter.retry_after(f"user-{i}")
|
||||||
|
assert len(limiter._hits) == 10001
|
||||||
|
|
||||||
|
# Следующий record() создаёт СВОЮ (непустую) корзину и заодно подчищает
|
||||||
|
# все чужие пустые — тот же порог (>10000), что и RateLimitMiddleware.
|
||||||
|
limiter.record("trigger-cleanup")
|
||||||
|
assert len(limiter._hits) < 10001
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,34 @@ def test_list_messages_without_auth_header_401(client: TestClient) -> None:
|
||||||
assert r.status_code == 401
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_whitespace_only_auth_header_401(client: TestClient) -> None:
|
||||||
|
"""review L4: заголовок из одних пробелов после `.strip()` пуст — не должен
|
||||||
|
считаться валидным auth (не проваливается тихо в "" как username)."""
|
||||||
|
r = client.get("/api/v1/trade-in/support/messages", headers={"x-authenticated-user": " "})
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_header_with_surrounding_whitespace_is_stripped(
|
||||||
|
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""review L4: лишний пробел от прокси не должен заводить ВТОРОЙ тред для, по
|
||||||
|
сути, того же пользователя — username резолвится в тред по .strip()-нутому
|
||||||
|
значению."""
|
||||||
|
seen_usernames = []
|
||||||
|
|
||||||
|
def fake_find_thread_id(db, username):
|
||||||
|
seen_usernames.append(username)
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(support_module.storage, "find_thread_id", fake_find_thread_id)
|
||||||
|
|
||||||
|
r = client.get(
|
||||||
|
"/api/v1/trade-in/support/messages", headers={"x-authenticated-user": " alice "}
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert seen_usernames == ["alice"]
|
||||||
|
|
||||||
|
|
||||||
# ── validation ────────────────────────────────────────────────────────────────
|
# ── validation ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -182,11 +210,22 @@ def test_send_message_support_chat_id_unset_returns_503(
|
||||||
def test_send_message_happy_path_mirrors_with_website_marker(
|
def test_send_message_happy_path_mirrors_with_website_marker(
|
||||||
client: TestClient, db: MagicMock, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
|
client: TestClient, db: MagicMock, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
|
||||||
) -> None:
|
) -> None:
|
||||||
monkeypatch.setattr(support_module.storage, "get_or_create_thread", lambda db, username: 7)
|
thread_calls: list[str] = []
|
||||||
|
|
||||||
|
def fake_get_or_create_thread(db, username):
|
||||||
|
thread_calls.append(username)
|
||||||
|
return 7
|
||||||
|
|
||||||
|
monkeypatch.setattr(support_module.storage, "get_or_create_thread", fake_get_or_create_thread)
|
||||||
recorded = {}
|
recorded = {}
|
||||||
|
|
||||||
def fake_record_inbound(db, *, thread_id, text_body, topic_message_id):
|
def fake_record_inbound(db, *, thread_id, text_body, topic_message_id, support_chat_id):
|
||||||
recorded.update(thread_id=thread_id, text_body=text_body, topic_message_id=topic_message_id)
|
recorded.update(
|
||||||
|
thread_id=thread_id,
|
||||||
|
text_body=text_body,
|
||||||
|
topic_message_id=topic_message_id,
|
||||||
|
support_chat_id=support_chat_id,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"id": 100,
|
"id": 100,
|
||||||
"direction": "in",
|
"direction": "in",
|
||||||
|
|
@ -216,9 +255,14 @@ def test_send_message_happy_path_mirrors_with_website_marker(
|
||||||
assert "У меня вопрос про trade-in" in mirror_call["text"]
|
assert "У меня вопрос про trade-in" in mirror_call["text"]
|
||||||
assert mirror_call["chat_id"] == support_module.settings.telegram_support_chat_id
|
assert mirror_call["chat_id"] == support_module.settings.telegram_support_chat_id
|
||||||
assert mirror_call["message_thread_id"] == 42
|
assert mirror_call["message_thread_id"] == 42
|
||||||
|
# review H1: интерактивный узкий бюджет ретраев/timeout, не воркерный дефолт.
|
||||||
|
assert mirror_call["max_retries"] == support_module._INTERACTIVE_SEND_MAX_RETRIES
|
||||||
|
assert mirror_call["timeout"] == support_module._INTERACTIVE_SEND_TIMEOUT_S
|
||||||
|
|
||||||
assert recorded["thread_id"] == 7
|
assert recorded["thread_id"] == 7
|
||||||
assert recorded["topic_message_id"] == 555 # из FakeTelegramClient.send_message result
|
assert recorded["topic_message_id"] == 555 # из FakeTelegramClient.send_message result
|
||||||
|
assert recorded["support_chat_id"] == support_module.settings.telegram_support_chat_id
|
||||||
|
assert thread_calls == ["kopylov"]
|
||||||
assert db.commit.called
|
assert db.commit.called
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -226,7 +270,12 @@ def test_send_message_telegram_failure_returns_502_and_does_not_persist(
|
||||||
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
|
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
|
||||||
) -> None:
|
) -> None:
|
||||||
_fake_telegram_client._response = TelegramApiError("sendMessage", 400, "chat not found")
|
_fake_telegram_client._response = TelegramApiError("sendMessage", 400, "chat not found")
|
||||||
monkeypatch.setattr(support_module.storage, "get_or_create_thread", lambda db, username: 7)
|
thread_created = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
support_module.storage,
|
||||||
|
"get_or_create_thread",
|
||||||
|
lambda db, username: thread_created.append(1),
|
||||||
|
)
|
||||||
record_called = []
|
record_called = []
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
support_module.storage,
|
support_module.storage,
|
||||||
|
|
@ -238,6 +287,8 @@ def test_send_message_telegram_failure_returns_502_and_does_not_persist(
|
||||||
|
|
||||||
assert r.status_code == 502
|
assert r.status_code == 502
|
||||||
assert record_called == [] # неотправленное сообщение не персистится
|
assert record_called == [] # неотправленное сообщение не персистится
|
||||||
|
# review H1: thread создаётся ПОСЛЕ успешной отправки — на неудаче до БД не доходит вообще.
|
||||||
|
assert thread_created == []
|
||||||
|
|
||||||
|
|
||||||
# ── rate limit ────────────────────────────────────────────────────────────────
|
# ── rate limit ────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -268,6 +319,40 @@ def test_send_message_rate_limited_429(client: TestClient, monkeypatch: pytest.M
|
||||||
assert "Retry-After" in second.headers
|
assert "Retry-After" in second.headers
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_message_failed_attempts_do_not_consume_rate_limit(
|
||||||
|
client: TestClient, monkeypatch: pytest.MonkeyPatch, _fake_telegram_client: Any
|
||||||
|
) -> None:
|
||||||
|
"""review L3: неудачная отправка НЕ должна расходовать rate-limit бюджет —
|
||||||
|
иначе клиент, которому не повезло с транзиентной Telegram-ошибкой, терял бы
|
||||||
|
попытки, не доставив НИ ОДНОГО сообщения."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
support_module, "_send_limiter", SlidingWindowLimiter(limit=1, window_s=60.0)
|
||||||
|
)
|
||||||
|
_fake_telegram_client._response = TelegramApiError("sendMessage", 500, "boom")
|
||||||
|
|
||||||
|
for _ in range(3):
|
||||||
|
r = client.post("/api/v1/trade-in/support/messages", json={"text": "hi"}, headers=_auth())
|
||||||
|
assert r.status_code == 502
|
||||||
|
|
||||||
|
# "Телеграм" снова работает — бюджет (лимит=1) всё ещё цел, ни одна неудачная
|
||||||
|
# попытка выше его не тронула.
|
||||||
|
_fake_telegram_client._response = {"message_id": 555}
|
||||||
|
monkeypatch.setattr(support_module.storage, "get_or_create_thread", lambda db, username: 1)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
support_module.storage,
|
||||||
|
"record_inbound",
|
||||||
|
lambda *a, **kw: {
|
||||||
|
"id": 1,
|
||||||
|
"direction": "in",
|
||||||
|
"text_body": kw["text_body"],
|
||||||
|
"operator_tg_id": None,
|
||||||
|
"created_at": "2026-07-26T00:00:00+00:00",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ok = client.post("/api/v1/trade-in/support/messages", json={"text": "ok"}, headers=_auth())
|
||||||
|
assert ok.status_code == 200, ok.text
|
||||||
|
|
||||||
|
|
||||||
def test_send_message_rate_limit_is_per_user(
|
def test_send_message_rate_limit_is_per_user(
|
||||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -332,7 +417,7 @@ def test_list_messages_returns_thread_scoped_rows(
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
support_module.storage,
|
support_module.storage,
|
||||||
"list_messages",
|
"list_messages",
|
||||||
lambda db, *, thread_id, since_id: [
|
lambda db, *, thread_id, since_id, limit: [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"direction": "in",
|
"direction": "in",
|
||||||
|
|
@ -351,6 +436,24 @@ def test_list_messages_returns_thread_scoped_rows(
|
||||||
assert body[0]["text_body"] == "hi"
|
assert body[0]["text_body"] == "hi"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_messages_passes_bounded_limit_to_storage(
|
||||||
|
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""review M5: без LIMIT каждое монтирование виджета отдавало бы весь тред."""
|
||||||
|
seen_limits = []
|
||||||
|
|
||||||
|
def fake_list_messages(db, *, thread_id, since_id, limit):
|
||||||
|
seen_limits.append(limit)
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(support_module.storage, "find_thread_id", lambda db, username: 7)
|
||||||
|
monkeypatch.setattr(support_module.storage, "list_messages", fake_list_messages)
|
||||||
|
|
||||||
|
r = client.get("/api/v1/trade-in/support/messages", headers=_auth())
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert seen_limits == [support_module._LIST_MESSAGES_LIMIT]
|
||||||
|
|
||||||
|
|
||||||
def test_two_users_get_independent_threads(
|
def test_two_users_get_independent_threads(
|
||||||
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -361,7 +464,7 @@ def test_two_users_get_independent_threads(
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
support_module.storage,
|
support_module.storage,
|
||||||
"list_messages",
|
"list_messages",
|
||||||
lambda db, *, thread_id, since_id: [
|
lambda db, *, thread_id, since_id, limit: [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"direction": "in",
|
"direction": "in",
|
||||||
|
|
|
||||||
|
|
@ -135,8 +135,8 @@ const EMPTY_OBJECT: ObjectInfo = {
|
||||||
repair: "—",
|
repair: "—",
|
||||||
balcony: false,
|
balcony: false,
|
||||||
locationCoef: "—",
|
locationCoef: "—",
|
||||||
streetView: "",
|
lat: null,
|
||||||
compass: "",
|
lon: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Skeleton pulse (opacity only — NO shimmer sweep, per .claude/rules/ui-*) +
|
// Skeleton pulse (opacity only — NO shimmer sweep, per .claude/rules/ui-*) +
|
||||||
|
|
@ -681,6 +681,13 @@ export default function TradeInV2Page() {
|
||||||
house_type: estimate.house_type ?? undefined,
|
house_type: estimate.house_type ?? undefined,
|
||||||
repair_state: estimate.repair_state ?? undefined,
|
repair_state: estimate.repair_state ?? undefined,
|
||||||
has_balcony: estimate.has_balcony ?? undefined,
|
has_balcony: estimate.has_balcony ?? undefined,
|
||||||
|
// Without these the 01 map falls back to its "pick an address"
|
||||||
|
// placeholder on every restored estimate (shared link, history,
|
||||||
|
// PDF flow) even though the address field is populated — the map
|
||||||
|
// keys off coords, not the address string, and only the geocode
|
||||||
|
// suggestion path used to supply them.
|
||||||
|
lat: estimate.target_lat ?? undefined,
|
||||||
|
lon: estimate.target_lon ?? undefined,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
[estimate],
|
[estimate],
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, type CSSProperties } from "react";
|
import { useEffect, useRef, useState, type CSSProperties } from "react";
|
||||||
|
|
||||||
import { API_BASE_URL } from "@/lib/api";
|
import { API_BASE_URL } from "@/lib/api";
|
||||||
import { safeUrl } from "@/lib/safeUrl";
|
import { safeUrl } from "@/lib/safeUrl";
|
||||||
|
|
@ -12,11 +12,6 @@ import type { HeroBarData } from "./mappers";
|
||||||
// Default presentation data (unwired usage): the existing design fixtures.
|
// Default presentation data (unwired usage): the existing design fixtures.
|
||||||
const HERO_FIXTURE: HeroBarData = { report, object };
|
const HERO_FIXTURE: HeroBarData = { report, object };
|
||||||
|
|
||||||
// next/image does NOT prepend the configured basePath ("/trade-in") to a
|
|
||||||
// literal src, so an <Image src="/trade-in-v2/…"> 404s behind Caddy. A plain
|
|
||||||
// <img> with the basePath baked in resolves to /trade-in/trade-in-v2/… → 200.
|
|
||||||
const BP = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
|
|
||||||
|
|
||||||
// estimate ids are server-issued UUIDs — reject anything else before it lands
|
// estimate ids are server-issued UUIDs — reject anything else before it lands
|
||||||
// in the PDF request path so a tampered id cannot be injected.
|
// in the PDF request path so a tampered id cannot be injected.
|
||||||
const PDF_UUID_RE =
|
const PDF_UUID_RE =
|
||||||
|
|
@ -37,6 +32,157 @@ function pdfDownloadHref(estimateId: string | null | undefined): string | null {
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Locator mini-map (Leaflet + OSM) ────────────────────────────────────────
|
||||||
|
// Replaces the old static building.png stock photo — user-reported bug: that
|
||||||
|
// single asset was shown for EVERY estimate regardless of the real address,
|
||||||
|
// misleading users into thinking they were looking at their own building.
|
||||||
|
// PORTS the Leaflet-CDN loader pattern from ./SourcesMap.tsx (itself ported
|
||||||
|
// from the dead v1 tree's MapCard.tsx) — copied rather than imported so this
|
||||||
|
// file stays a self-contained port with no shared runtime module and no npm
|
||||||
|
// Leaflet dep, same rationale as SourcesMap.
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet (см. SourcesMap.tsx) */
|
||||||
|
const LEAFLET_VER = "1.9.4";
|
||||||
|
const LEAFLET_CSS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.css`;
|
||||||
|
const LEAFLET_JS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.js`;
|
||||||
|
const LEAFLET_CSS_SRI = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=";
|
||||||
|
const LEAFLET_JS_SRI = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=";
|
||||||
|
|
||||||
|
/** Подгружает Leaflet с CDN один раз, резолвит window.L. (mirror SourcesMap.tsx) */
|
||||||
|
function loadLeaflet(): Promise<any> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const w = window as any;
|
||||||
|
if (w.L) {
|
||||||
|
resolve(w.L);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!document.querySelector(`link[data-leaflet]`)) {
|
||||||
|
const link = document.createElement("link");
|
||||||
|
link.rel = "stylesheet";
|
||||||
|
link.href = LEAFLET_CSS;
|
||||||
|
link.integrity = LEAFLET_CSS_SRI;
|
||||||
|
link.crossOrigin = "anonymous";
|
||||||
|
link.setAttribute("data-leaflet", "1");
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
const existing = document.querySelector<HTMLScriptElement>(`script[data-leaflet]`);
|
||||||
|
if (existing) {
|
||||||
|
existing.addEventListener("load", () => resolve(w.L));
|
||||||
|
existing.addEventListener("error", () => reject(new Error("leaflet load failed")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = LEAFLET_JS;
|
||||||
|
script.integrity = LEAFLET_JS_SRI;
|
||||||
|
script.crossOrigin = "anonymous";
|
||||||
|
script.setAttribute("data-leaflet", "1");
|
||||||
|
script.onload = () => resolve(w.L);
|
||||||
|
script.onerror = () => reject(new Error("leaflet load failed"));
|
||||||
|
document.body.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overview zoom: close enough to recognise the actual building on a 560×152
|
||||||
|
// box without feeling zoomed-in on bare rooftops (SourcesMap's multi-pin
|
||||||
|
// overlay map fits bounds instead — this is a single-point locator, not an
|
||||||
|
// exploration map).
|
||||||
|
const HERO_MAP_ZOOM = 16;
|
||||||
|
|
||||||
|
interface HeroMiniMapProps {
|
||||||
|
lat: number | null;
|
||||||
|
lon: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small OSM/Leaflet locator map centred on the subject address, replacing the
|
||||||
|
* old static building.png photo. Deliberately near-non-interactive — this is
|
||||||
|
* a 560×152 "you are here" badge, not an explorable map (ParamsPanel/
|
||||||
|
* SourcesMap already cover that): dragging/zoomControl/scroll-zoom/dbl-click
|
||||||
|
* zoom are all off so the box reads as a locator, not a broken-feeling mini
|
||||||
|
* map, and never steals the page's scroll or click focus.
|
||||||
|
*/
|
||||||
|
function HeroMiniMap({ lat, lon }: HeroMiniMapProps) {
|
||||||
|
const mapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [mapError, setMapError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (lat == null || lon == null) return;
|
||||||
|
let map: any = null;
|
||||||
|
let cancelled = false;
|
||||||
|
// Reset a stale failure from a previous address/CDN hiccup before this
|
||||||
|
// attempt — otherwise one bad load latches mapError forever (caught in
|
||||||
|
// review on a sibling PR) and every later estimate shows the placeholder
|
||||||
|
// even once the CDN is reachable again.
|
||||||
|
setMapError(false);
|
||||||
|
|
||||||
|
loadLeaflet()
|
||||||
|
.then((L) => {
|
||||||
|
if (cancelled || !mapRef.current) return;
|
||||||
|
map = L.map(mapRef.current, {
|
||||||
|
scrollWheelZoom: false, // embedded in the page — must not steal page scroll
|
||||||
|
dragging: false, // locator badge, not an explorable map
|
||||||
|
touchZoom: false,
|
||||||
|
doubleClickZoom: false,
|
||||||
|
zoomControl: false, // no room for +/- controls at this size
|
||||||
|
keyboard: false,
|
||||||
|
}).setView([lat, lon], HERO_MAP_ZOOM);
|
||||||
|
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||||
|
attribution: "© OpenStreetMap",
|
||||||
|
maxZoom: 19,
|
||||||
|
}).addTo(map);
|
||||||
|
L.circleMarker([lat, lon], {
|
||||||
|
radius: 8,
|
||||||
|
color: "#fff",
|
||||||
|
weight: 3,
|
||||||
|
fillColor: tokens.accent,
|
||||||
|
fillOpacity: 1,
|
||||||
|
}).addTo(map);
|
||||||
|
setTimeout(() => map && map.invalidateSize(), 120);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setMapError(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (map) map.remove();
|
||||||
|
};
|
||||||
|
}, [lat, lon]);
|
||||||
|
|
||||||
|
// Honest empty states — no coordinates yet (fresh/un-geocoded estimate) or
|
||||||
|
// the CDN failed — never a blank rectangle or a broken-image icon.
|
||||||
|
if (lat == null || lon == null || mapError) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: tokens.mapBg,
|
||||||
|
color: tokens.muted,
|
||||||
|
fontSize: 11,
|
||||||
|
textAlign: "center",
|
||||||
|
padding: "0 20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lat == null || lon == null
|
||||||
|
? "Карта появится после расчёта адреса"
|
||||||
|
: "Не удалось загрузить карту"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={mapRef}
|
||||||
|
style={{ position: "absolute", inset: 0, background: tokens.mapBg }}
|
||||||
|
aria-label="Расположение объекта на карте"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
interface HeroBarProps {
|
interface HeroBarProps {
|
||||||
data?: HeroBarData;
|
data?: HeroBarData;
|
||||||
estimateId?: string | null;
|
estimateId?: string | null;
|
||||||
|
|
@ -46,10 +192,10 @@ interface HeroBarProps {
|
||||||
hasEstimate: boolean;
|
hasEstimate: boolean;
|
||||||
onOpenInfo: () => void;
|
onOpenInfo: () => void;
|
||||||
// #2275 mobile quick-view: a real fluid layout instead of the fixed-width
|
// #2275 mobile quick-view: a real fluid layout instead of the fixed-width
|
||||||
// desktop one — meta/buttons stack, the decorative building photo (and the
|
// desktop one — meta/buttons stack, the locator mini-map (and the
|
||||||
// address/coef card baked into it) is dropped since it assumes a 560×152 box
|
// address/coef card baked into it) is dropped since it assumes a 560×152 box
|
||||||
// that cannot reflow. «КАК РАССЧИТАНО» still opens the same location-coef
|
// that cannot reflow. «КАК РАССЧИТАНО» still opens the same location-coef
|
||||||
// drawer, so no functionality is lost, only the redundant photo-card copy.
|
// drawer, so no functionality is lost, only the redundant map-card copy.
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,9 +238,6 @@ export default function HeroBar({
|
||||||
// A downloadable report exists ⇔ the estimate is ready ⇒ the PDF button is the
|
// A downloadable report exists ⇔ the estimate is ready ⇒ the PDF button is the
|
||||||
// filled/primary CTA (M4). Otherwise it stays a disabled outline.
|
// filled/primary CTA (M4). Otherwise it stays a disabled outline.
|
||||||
const pdfFilled = Boolean(pdfHref);
|
const pdfFilled = Boolean(pdfHref);
|
||||||
// Hide the building photo if the asset 404s/400s so the photoBg fill shows
|
|
||||||
// instead of a broken-image icon.
|
|
||||||
const [imgFailed, setImgFailed] = useState(false);
|
|
||||||
const pdfBtnInner = (filled: boolean) => {
|
const pdfBtnInner = (filled: boolean) => {
|
||||||
const frame = filled ? "#fff" : "#2e8bff";
|
const frame = filled ? "#fff" : "#2e8bff";
|
||||||
const rule = filled ? "rgba(255,255,255,.75)" : "#6f8195";
|
const rule = filled ? "rgba(255,255,255,.75)" : "#6f8195";
|
||||||
|
|
@ -137,7 +280,6 @@ export default function HeroBar({
|
||||||
.hero-pdf-btn-filled:active { transform: translateY(1px); }
|
.hero-pdf-btn-filled:active { transform: translateY(1px); }
|
||||||
.hero-calc-btn:hover { border-color: ${tokens.accent}; color: ${tokens.accent}; }
|
.hero-calc-btn:hover { border-color: ${tokens.accent}; color: ${tokens.accent}; }
|
||||||
.hero-coef-row:hover { background: rgba(46,139,255,.09); }
|
.hero-coef-row:hover { background: rgba(46,139,255,.09); }
|
||||||
@keyframes hero-scanv { 0% { transform: translateY(-100%); } 100% { transform: translateY(900%); } }
|
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
{/* LEFT: meta + buttons */}
|
{/* LEFT: meta + buttons */}
|
||||||
|
|
@ -295,11 +437,15 @@ export default function HeroBar({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RIGHT: photo + overlays — dropped in compact mode (#2275): the box is
|
{/* RIGHT: locator mini-map + overlays — dropped in compact mode (#2275):
|
||||||
a fixed 560×152 with several absolutely-positioned children (address
|
the box is a fixed 560×152 with several absolutely-positioned
|
||||||
card, compass, distance scale) pinned to that size, so it cannot
|
children (address card) pinned to that size, so it cannot reflow to
|
||||||
reflow to a phone width. «КАК РАССЧИТАНО» above still opens the same
|
a phone width. «КАК РАССЧИТАНО» above still opens the same
|
||||||
location-coef drawer, so no functionality is lost. */}
|
location-coef drawer, so no functionality is lost.
|
||||||
|
User-reported bug: this used to be a single static building.png
|
||||||
|
photo shown for EVERY estimate regardless of the real address (a
|
||||||
|
user could be looking at someone else's building) — replaced with a
|
||||||
|
real Leaflet/OSM map centred on the subject's own coordinates. */}
|
||||||
{!compact && (
|
{!compact && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -310,65 +456,29 @@ export default function HeroBar({
|
||||||
border: `1px solid ${tokens.line3}`,
|
border: `1px solid ${tokens.line3}`,
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
background: tokens.photoBg,
|
background: tokens.mapBg,
|
||||||
boxShadow: "0 6px 26px rgba(40,80,130,.10)",
|
boxShadow: "0 6px 26px rgba(40,80,130,.10)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!imgFailed && (
|
<HeroMiniMap lat={data.object.lat} lon={data.object.lon} />
|
||||||
<img
|
|
||||||
src={`${BP}/trade-in-v2/building.png`}
|
{/* Left fade so the address card (below) stays legible over busy
|
||||||
alt=""
|
map tiles instead of a floating card with no visual anchor — kept
|
||||||
onError={() => setImgFailed(true)}
|
from the old photo styling, where it served the same purpose.
|
||||||
style={{
|
pointerEvents:none so it never blocks map interaction/attribution
|
||||||
position: "absolute",
|
underneath. The old bottom vignette + scanning HUD sweep line are
|
||||||
inset: 0,
|
dropped: both existed purely to stylise/recede the stock photo and
|
||||||
width: "100%",
|
have no equivalent purpose over a live basemap. */}
|
||||||
height: "100%",
|
|
||||||
objectFit: "contain",
|
|
||||||
objectPosition: "right center",
|
|
||||||
filter: "saturate(.25) brightness(1.06) contrast(.95)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{/* blue duotone tint toward HUD accent — recedes the photo (only over
|
|
||||||
the real image; skip when it 404s so the placeholder stays clean) */}
|
|
||||||
{!imgFailed && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
inset: 0,
|
|
||||||
background: "rgba(46,139,255,.14)",
|
|
||||||
mixBlendMode: "multiply",
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
background:
|
background:
|
||||||
"linear-gradient(90deg,rgba(238,244,250,.96) 0%,rgba(238,244,250,.5) 22%,transparent 40%)",
|
"linear-gradient(90deg,rgba(238,244,250,.96) 0%,rgba(238,244,250,.5) 22%,transparent 40%)",
|
||||||
}}
|
pointerEvents: "none",
|
||||||
/>
|
// Above Leaflet's tile pane (200) / overlay pane (400); below the
|
||||||
<div
|
// marker (600) so the subject pin still reads through the fade.
|
||||||
style={{
|
zIndex: 500,
|
||||||
position: "absolute",
|
|
||||||
inset: 0,
|
|
||||||
background:
|
|
||||||
"linear-gradient(0deg,rgba(230,240,250,.45),transparent 40%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
top: "34%",
|
|
||||||
height: 1,
|
|
||||||
background:
|
|
||||||
"linear-gradient(90deg,transparent,rgba(46,139,255,.5),transparent)",
|
|
||||||
animation: "hero-scanv 6s linear infinite",
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -380,6 +490,15 @@ export default function HeroBar({
|
||||||
top: "50%",
|
top: "50%",
|
||||||
transform: "translateY(-50%)",
|
transform: "translateY(-50%)",
|
||||||
width: 182,
|
width: 182,
|
||||||
|
// MUST be set explicitly. .leaflet-container is position:relative
|
||||||
|
// with z-index:auto, so it does NOT open a stacking context — its
|
||||||
|
// internal panes (tiles 200 … popup 700) compete directly with
|
||||||
|
// these siblings, and an auto/0 card is painted UNDER the map.
|
||||||
|
// Shipped without this in #2529 and the address + location figure
|
||||||
|
// vanished from the hero on prod. 750 clears every pane; the
|
||||||
|
// attribution control (800) sits bottom-right and never overlaps
|
||||||
|
// this left-anchored card, so its link stays clickable.
|
||||||
|
zIndex: 750,
|
||||||
background: tokens.surface.w72,
|
background: tokens.surface.w72,
|
||||||
backdropFilter: "blur(5px)",
|
backdropFilter: "blur(5px)",
|
||||||
border: `1px solid ${tokens.line}`,
|
border: `1px solid ${tokens.line}`,
|
||||||
|
|
@ -406,31 +525,6 @@ export default function HeroBar({
|
||||||
{data.object.city}
|
{data.object.city}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* streetView — Fix #4 (audit): always "" (TODO BE-3, mappers.ts
|
|
||||||
mapObject), so this slot + its divider are hidden together
|
|
||||||
rather than showing an empty caption row. Leaves the single
|
|
||||||
divider below to separate the address block from the coef row. */}
|
|
||||||
{data.object.streetView && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: 1,
|
|
||||||
background: tokens.lineSoft,
|
|
||||||
margin: "8px 0",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: tokens.font.mono,
|
|
||||||
fontSize: 9,
|
|
||||||
color: tokens.muted,
|
|
||||||
lineHeight: 1.55,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{data.object.streetView}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: 1,
|
height: 1,
|
||||||
|
|
@ -528,54 +622,14 @@ export default function HeroBar({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* compass — Fix #4 (audit): compass bearing is always "" (TODO BE-3,
|
|
||||||
mappers.ts mapObject), so the icon+label are hidden rather than
|
|
||||||
showing a compass that never actually points anywhere. Mirrors the
|
|
||||||
locationCoef "нет данных" graceful-fallback pattern already used
|
|
||||||
in this file. */}
|
|
||||||
{data.object.compass && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
right: 16,
|
|
||||||
top: 14,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 2,
|
|
||||||
color: tokens.accent,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="22"
|
|
||||||
height="22"
|
|
||||||
viewBox="0 0 22 22"
|
|
||||||
fill="none"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<circle cx="11" cy="11" r="10" stroke="#2e8bff" strokeWidth="1" />
|
|
||||||
<path d="M11 3 L13 11 L11 9 L9 11 Z" fill="#2e8bff" />
|
|
||||||
</svg>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: tokens.font.mono,
|
|
||||||
fontSize: 8,
|
|
||||||
letterSpacing: ".5px",
|
|
||||||
color: tokens.muted,
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{data.object.compass}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Fix #3 (audit) — the "0/25/50/75/100м" distance ruler here was a
|
{/* Fix #3 (audit) — the "0/25/50/75/100м" distance ruler here was a
|
||||||
hardcoded tick scale over a generic stock photo with no real
|
hardcoded tick scale over a generic stock photo with no real
|
||||||
measurement behind it (not tied to any actual distance/scale
|
measurement behind it (not tied to any actual distance/scale
|
||||||
value). Removed rather than fabricating a scale. */}
|
value). Removed rather than fabricating a scale. */}
|
||||||
|
|
||||||
{/* corner brackets */}
|
{/* corner brackets — decorative HUD framing only; pointerEvents:none
|
||||||
|
so they never sit on top of the map's own bottom-right OSM
|
||||||
|
attribution link. */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
|
|
@ -585,6 +639,7 @@ export default function HeroBar({
|
||||||
height: 14,
|
height: 14,
|
||||||
borderLeft: `1.5px solid ${tokens.accent}`,
|
borderLeft: `1.5px solid ${tokens.accent}`,
|
||||||
borderTop: `1.5px solid ${tokens.accent}`,
|
borderTop: `1.5px solid ${tokens.accent}`,
|
||||||
|
pointerEvents: "none",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
|
|
@ -596,6 +651,7 @@ export default function HeroBar({
|
||||||
height: 14,
|
height: 14,
|
||||||
borderRight: `1.5px solid ${tokens.accent}`,
|
borderRight: `1.5px solid ${tokens.accent}`,
|
||||||
borderTop: `1.5px solid ${tokens.accent}`,
|
borderTop: `1.5px solid ${tokens.accent}`,
|
||||||
|
pointerEvents: "none",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
|
|
@ -607,6 +663,7 @@ export default function HeroBar({
|
||||||
height: 14,
|
height: 14,
|
||||||
borderLeft: `1.5px solid ${tokens.accent}`,
|
borderLeft: `1.5px solid ${tokens.accent}`,
|
||||||
borderBottom: `1.5px solid ${tokens.accent}`,
|
borderBottom: `1.5px solid ${tokens.accent}`,
|
||||||
|
pointerEvents: "none",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
|
|
@ -618,6 +675,7 @@ export default function HeroBar({
|
||||||
height: 14,
|
height: 14,
|
||||||
borderRight: `1.5px solid ${tokens.accent}`,
|
borderRight: `1.5px solid ${tokens.accent}`,
|
||||||
borderBottom: `1.5px solid ${tokens.accent}`,
|
borderBottom: `1.5px solid ${tokens.accent}`,
|
||||||
|
pointerEvents: "none",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,16 @@
|
||||||
// styles are UNCHANGED — only the data plumbing differs (display <div>s became
|
// styles are UNCHANGED — only the data plumbing differs (display <div>s became
|
||||||
// <input>s styled identically, dropdowns now feed real enum values). RU dropdown
|
// <input>s styled identically, dropdowns now feed real enum values). RU dropdown
|
||||||
// labels <-> API enum values go through HOUSE_TYPE_*/REPAIR_* maps in ./mappers.
|
// labels <-> API enum values go through HOUSE_TYPE_*/REPAIR_* maps in ./mappers.
|
||||||
// РАДИУС is now wired (radius_m on submit + the outer map ring scales with it);
|
// РАДИУС is now wired (radius_m on submit + the real map circle below scales
|
||||||
// the CRM dropdown still has no backend → kept visually but disabled. Hover/active
|
// with it); the CRM dropdown still has no backend → kept visually but disabled.
|
||||||
// + @keyframes live in a pp-prefixed local <style>.
|
// Hover/active + @keyframes live in a pp-prefixed local <style>.
|
||||||
|
//
|
||||||
|
// Fix (audit): the 01 map was a decorative SVG (grid + fake streets + radius
|
||||||
|
// rings) with no real coordinates behind it. It is now a real Leaflet + OSM
|
||||||
|
// basemap, ported from the same CDN-loader pattern as ./SourcesMap.tsx /
|
||||||
|
// ../MapPicker.tsx (copied rather than shared — same self-contained-port
|
||||||
|
// convention as SourcesMap.tsx, no npm Leaflet dep).
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet (см. SourcesMap.tsx) */
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useEffect,
|
useEffect,
|
||||||
|
|
@ -143,6 +150,97 @@ function comboKeyDown(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 01 map — Leaflet + OSM (real coordinates) ───────────────────────────────
|
||||||
|
// Same CDN loader pattern/version/SRI as ./SourcesMap.tsx and ../MapPicker.tsx
|
||||||
|
// (duplicated on purpose — each v2 file is a self-contained port, no shared
|
||||||
|
// runtime module, no npm Leaflet dep).
|
||||||
|
const LEAFLET_VER = "1.9.4";
|
||||||
|
const LEAFLET_CSS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.css`;
|
||||||
|
const LEAFLET_JS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.js`;
|
||||||
|
const LEAFLET_CSS_SRI = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=";
|
||||||
|
const LEAFLET_JS_SRI = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=";
|
||||||
|
const MAP_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
|
||||||
|
const MAP_ATTRIBUTION = "© OpenStreetMap";
|
||||||
|
const DEFAULT_MAP_ZOOM = 16;
|
||||||
|
const MIN_MAP_ZOOM = 11;
|
||||||
|
const MAX_MAP_ZOOM = 19;
|
||||||
|
|
||||||
|
/** Подгружает Leaflet с CDN один раз, резолвит window.L. (mirror SourcesMap.tsx) */
|
||||||
|
function loadLeaflet(): Promise<any> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const w = window as any;
|
||||||
|
if (w.L) {
|
||||||
|
resolve(w.L);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!document.querySelector(`link[data-leaflet]`)) {
|
||||||
|
const link = document.createElement("link");
|
||||||
|
link.rel = "stylesheet";
|
||||||
|
link.href = LEAFLET_CSS;
|
||||||
|
link.integrity = LEAFLET_CSS_SRI;
|
||||||
|
link.crossOrigin = "anonymous";
|
||||||
|
link.setAttribute("data-leaflet", "1");
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
const existing = document.querySelector<HTMLScriptElement>(`script[data-leaflet]`);
|
||||||
|
if (existing) {
|
||||||
|
existing.addEventListener("load", () => resolve(w.L));
|
||||||
|
existing.addEventListener("error", () => reject(new Error("leaflet load failed")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = LEAFLET_JS;
|
||||||
|
script.integrity = LEAFLET_JS_SRI;
|
||||||
|
script.crossOrigin = "anonymous";
|
||||||
|
script.setAttribute("data-leaflet", "1");
|
||||||
|
script.onload = () => resolve(w.L);
|
||||||
|
script.onerror = () => reject(new Error("leaflet load failed"));
|
||||||
|
document.body.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Экранирование перед вставкой в raw-HTML Leaflet divIcon (адрес — ввод
|
||||||
|
* пользователя). Тот же паттерн, что и esc() в SourcesMap.tsx. */
|
||||||
|
function escapeMapHtml(s: string): string {
|
||||||
|
return s.replace(/[&<>"']/g, (c) =>
|
||||||
|
c === "&"
|
||||||
|
? "&"
|
||||||
|
: c === "<"
|
||||||
|
? "<"
|
||||||
|
: c === ">"
|
||||||
|
? ">"
|
||||||
|
: c === '"'
|
||||||
|
? """
|
||||||
|
: "'",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пин квартиры-предмета оценки — divIcon 1:1 повторяет прежний SVG-бабл
|
||||||
|
* (акцентная рамка, точка, адрес + площадь, треугольник-указатель), только
|
||||||
|
* теперь висит на реальных координатах реальной карты. */
|
||||||
|
function buildSubjectIcon(
|
||||||
|
L: any,
|
||||||
|
addressLabel: string,
|
||||||
|
areaLabel: string | null,
|
||||||
|
): any {
|
||||||
|
const html = `
|
||||||
|
<div style="position:relative;width:220px;height:56px;pointer-events:none">
|
||||||
|
<div style="position:absolute;left:50%;bottom:9px;transform:translateX(-50%);display:flex;align-items:center;gap:6px;background:${tokens.surface.w85};border:1px solid ${tokens.accent};border-radius:4px;padding:4px 8px;white-space:nowrap;box-shadow:0 3px 10px rgba(46,139,255,.25);font-family:${tokens.font.sans}">
|
||||||
|
<span style="width:7px;height:7px;border-radius:50%;background:${tokens.accent};flex:0 0 auto"></span>
|
||||||
|
<span style="display:flex;flex-direction:column;gap:1px">
|
||||||
|
<span style="font-size:11px;font-weight:600;color:${tokens.ink}">${escapeMapHtml(addressLabel)}</span>
|
||||||
|
${
|
||||||
|
areaLabel
|
||||||
|
? `<span style="font-size:9px;color:${tokens.muted}">${escapeMapHtml(areaLabel)}</span>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style="position:absolute;left:50%;bottom:0;transform:translateX(-50%);width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;border-top:7px solid ${tokens.accent}"></div>
|
||||||
|
</div>`;
|
||||||
|
return L.divIcon({ html, className: "", iconSize: [220, 56], iconAnchor: [110, 56] });
|
||||||
|
}
|
||||||
|
|
||||||
function Dd({
|
function Dd({
|
||||||
open,
|
open,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
|
@ -425,21 +523,6 @@ const errorText: CSSProperties = {
|
||||||
color: tokens.danger,
|
color: tokens.danger,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Analog price pin on the 01 map. left/top come from mapMarkers() per-analog
|
|
||||||
// (projected from the real estimate); the chrome matches the former fixture
|
|
||||||
// pins 1:1 — only the data source changed (Finding #2).
|
|
||||||
const analogPin: CSSProperties = {
|
|
||||||
position: "absolute",
|
|
||||||
background: tokens.surface.w70,
|
|
||||||
border: `1px solid ${tokens.line}`,
|
|
||||||
borderRadius: 4,
|
|
||||||
padding: "3px 7px",
|
|
||||||
fontFamily: tokens.font.mono,
|
|
||||||
fontSize: 9,
|
|
||||||
lineHeight: 1.4,
|
|
||||||
color: tokens.ink,
|
|
||||||
};
|
|
||||||
|
|
||||||
// РАДИУС dropdown panel — mirrors the <Dd> HUD panel (surface.w98 + soft blue
|
// РАДИУС dropdown panel — mirrors the <Dd> HUD panel (surface.w98 + soft blue
|
||||||
// shadow), sized to the narrow radius trigger and dropped just beneath it.
|
// shadow), sized to the narrow radius trigger and dropped just beneath it.
|
||||||
const radiusPanel: CSSProperties = {
|
const radiusPanel: CSSProperties = {
|
||||||
|
|
@ -468,8 +551,13 @@ interface ParamsPanelProps {
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
/** Prefill for restore-by-id (?id=) — maps API enums back to RU dropdown labels. */
|
/** Prefill for restore-by-id (?id=) — maps API enums back to RU dropdown labels. */
|
||||||
initialValues?: Partial<TradeInEstimateInput>;
|
initialValues?: Partial<TradeInEstimateInput>;
|
||||||
/** Analog price pins for the 01 map, projected from the real estimate via
|
/** Analog price pins, projected from the real estimate via mapMarkers() onto
|
||||||
* mapMarkers(). Default [] → no price pins (never the fabricated fixtures). */
|
* the OLD decorative SVG's fixed 0-100% grid (never a real geo scale — see
|
||||||
|
* mapMarkers() comment in ./mappers.ts). Kept in the prop contract for
|
||||||
|
* backward-compat with the caller (app/v2/page.tsx); intentionally NOT
|
||||||
|
* plotted on the real Leaflet map below, because their %-positions do not
|
||||||
|
* correspond to real lat/lon at the map's actual zoom — projecting them
|
||||||
|
* would be a new, subtler version of the honesty bug this map replaces. */
|
||||||
markers?: MapMarker[];
|
markers?: MapMarker[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -495,6 +583,12 @@ function initRepairLabel(rs: RepairState | undefined): string {
|
||||||
// both ("ищем строго в пределах X м"). Design dropdown was values-only.
|
// both ("ищем строго в пределах X м"). Design dropdown was values-only.
|
||||||
const RADIUS_OPTIONS = ["Авто", "300 м", "500 м", "1000 м", "2000 м"];
|
const RADIUS_OPTIONS = ["Авто", "300 м", "500 м", "1000 м", "2000 м"];
|
||||||
|
|
||||||
|
// "Авто" sends no radius_m → the backend applies its two-tier default (1000 m
|
||||||
|
// primary / 2000 m fallback, see RADIUS_OPTIONS comment above). The map circle
|
||||||
|
// previews the PRIMARY tier so it is never wildly off from what the backend
|
||||||
|
// will actually use.
|
||||||
|
const AUTO_RADIUS_PREVIEW_M = 1000;
|
||||||
|
|
||||||
// radius_m (metres) -> dropdown label. null/absent → "Авто" (legacy two-tier
|
// radius_m (metres) -> dropdown label. null/absent → "Авто" (legacy two-tier
|
||||||
// default), so a re-submit never silently narrows the search.
|
// default), so a re-submit never silently narrows the search.
|
||||||
function initRadiusLabel(radiusM: number | null | undefined): string {
|
function initRadiusLabel(radiusM: number | null | undefined): string {
|
||||||
|
|
@ -509,7 +603,9 @@ export default function ParamsPanel({
|
||||||
hasEstimate = false,
|
hasEstimate = false,
|
||||||
error = null,
|
error = null,
|
||||||
initialValues,
|
initialValues,
|
||||||
markers = [],
|
// markers intentionally not destructured — see the ParamsPanelProps.markers
|
||||||
|
// doc comment: its %-positions belong to the retired decorative SVG grid and
|
||||||
|
// do not correspond to real lat/lon on the Leaflet map below.
|
||||||
}: ParamsPanelProps) {
|
}: ParamsPanelProps) {
|
||||||
// M4 — once a result is on screen the primary CTA is «СКАЧАТЬ PDF-ОТЧЁТ»
|
// M4 — once a result is on screen the primary CTA is «СКАЧАТЬ PDF-ОТЧЁТ»
|
||||||
// (HeroBar); this button (a re-run) demotes to a secondary outline.
|
// (HeroBar); this button (a re-run) demotes to a secondary outline.
|
||||||
|
|
@ -518,11 +614,6 @@ export default function ParamsPanel({
|
||||||
// РАДИУС combobox a11y state (aria-activedescendant highlight + listbox id).
|
// РАДИУС combobox a11y state (aria-activedescendant highlight + listbox id).
|
||||||
const radiusListId = useId();
|
const radiusListId = useId();
|
||||||
const [radiusActive, setRadiusActive] = useState(-1);
|
const [radiusActive, setRadiusActive] = useState(-1);
|
||||||
// Map zoom — applied as transform: scale() on the map content layer (not the
|
|
||||||
// whole panel). Default 1 (identity → pixel-identical), step .25, clamp 1–2.5
|
|
||||||
// (no zoom-out below 1: there is no real basemap behind the blueprint SVG, so
|
|
||||||
// shrinking would only expose empty corners — zoom-in only).
|
|
||||||
const [zoom, setZoom] = useState(1);
|
|
||||||
const [address, setAddress] = useState(initialValues?.address ?? "");
|
const [address, setAddress] = useState(initialValues?.address ?? "");
|
||||||
const [area, setArea] = useState(
|
const [area, setArea] = useState(
|
||||||
initialValues?.area_m2 != null ? String(initialValues.area_m2) : "",
|
initialValues?.area_m2 != null ? String(initialValues.area_m2) : "",
|
||||||
|
|
@ -776,21 +867,138 @@ export default function ParamsPanel({
|
||||||
transition: "all .15s",
|
transition: "all .15s",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Outer radius ring tracks the selected РАДИУС (subtle, best-effort): 500 м
|
// Real analysis-radius circle (metres), synced with the selected РАДИУС —
|
||||||
// keeps the design's r=112 exactly; other values scale gently (and clamp) so the
|
// L.circle takes a radius in metres, so this is a true geographic scale
|
||||||
// ring never blows past the map box. The inner two rings stay fixed.
|
// (unlike the old SVG ring, which was a clamped pixel best-effort).
|
||||||
const radiusM = parseInt(radius, 10);
|
const parsedRadiusM = parseInt(radius, 10);
|
||||||
const outerRingR = Number.isFinite(radiusM)
|
const circleRadiusM = Number.isFinite(parsedRadiusM)
|
||||||
? Math.round(
|
? parsedRadiusM
|
||||||
Math.max(70, Math.min(155, 112 * Math.pow(radiusM / 500, 0.35))),
|
: AUTO_RADIUS_PREVIEW_M;
|
||||||
)
|
|
||||||
: 112;
|
|
||||||
|
|
||||||
// Subject area caption for the map pin (M10) — the SUBJECT's own m², from the
|
// Subject area caption for the map pin (M10) — the SUBJECT's own m², from the
|
||||||
// form, so the pin never borrows an analog's area. Empty area → no caption.
|
// form, so the pin never borrows an analog's area. Empty area → no caption.
|
||||||
const areaTrimmed = area.trim();
|
const areaTrimmed = area.trim();
|
||||||
const subjectAreaLabel = areaTrimmed ? `${areaTrimmed} м²` : null;
|
const subjectAreaLabel = areaTrimmed ? `${areaTrimmed} м²` : null;
|
||||||
|
|
||||||
|
// ── 01 map — real Leaflet + OSM (see loadLeaflet()/buildSubjectIcon() above) ──
|
||||||
|
const hasCoords = coords != null;
|
||||||
|
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const leafletMapRef = useRef<any>(null);
|
||||||
|
const markerRef = useRef<any>(null);
|
||||||
|
const circleRef = useRef<any>(null);
|
||||||
|
const [mapZoom, setMapZoom] = useState(DEFAULT_MAP_ZOOM);
|
||||||
|
const [mapLoadError, setMapLoadError] = useState(false);
|
||||||
|
|
||||||
|
// "Latest value" refs, read inside the async loadLeaflet().then() callback
|
||||||
|
// below, which may resolve several renders after the effect fired (slow CDN
|
||||||
|
// load). Plain closure vars would go stale; refs assigned every render don't.
|
||||||
|
const addressRef = useRef(address);
|
||||||
|
addressRef.current = address;
|
||||||
|
const subjectAreaLabelRef = useRef(subjectAreaLabel);
|
||||||
|
subjectAreaLabelRef.current = subjectAreaLabel;
|
||||||
|
const circleRadiusMRef = useRef(circleRadiusM);
|
||||||
|
circleRadiusMRef.current = circleRadiusM;
|
||||||
|
const coordsRef = useRef(coords);
|
||||||
|
coordsRef.current = coords;
|
||||||
|
|
||||||
|
// Create/destroy the map when coordinates appear/disappear (not on every
|
||||||
|
// lat/lon value change — see the position-sync effect below for that). This
|
||||||
|
// is the only effect that mounts/tears down the Leaflet instance, so it is
|
||||||
|
// also the only place map.remove() needs to run, fixing the "Map container
|
||||||
|
// is already initialized" crash class.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasCoords) return; // no coords yet -> the placeholder renders instead
|
||||||
|
let map: any = null;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
// Reset a previous CDN failure before retrying. Without this the error
|
||||||
|
// branch is a dead end: it replaces the map container in the render tree,
|
||||||
|
// so mapContainerRef stays null and no later attempt can ever succeed —
|
||||||
|
// one transient unpkg blip would kill the map for the rest of the session
|
||||||
|
// even after the user picks a different address.
|
||||||
|
setMapLoadError(false);
|
||||||
|
|
||||||
|
loadLeaflet()
|
||||||
|
.then((L) => {
|
||||||
|
if (cancelled || !mapContainerRef.current || !coordsRef.current) return;
|
||||||
|
const center: [number, number] = [
|
||||||
|
coordsRef.current.lat,
|
||||||
|
coordsRef.current.lon,
|
||||||
|
];
|
||||||
|
map = L.map(mapContainerRef.current, {
|
||||||
|
scrollWheelZoom: false,
|
||||||
|
zoomControl: false,
|
||||||
|
minZoom: MIN_MAP_ZOOM,
|
||||||
|
maxZoom: MAX_MAP_ZOOM,
|
||||||
|
}).setView(center, DEFAULT_MAP_ZOOM);
|
||||||
|
L.tileLayer(MAP_TILE_URL, {
|
||||||
|
attribution: MAP_ATTRIBUTION,
|
||||||
|
maxZoom: MAX_MAP_ZOOM,
|
||||||
|
}).addTo(map);
|
||||||
|
setTimeout(() => map && map.invalidateSize(), 120);
|
||||||
|
|
||||||
|
circleRef.current = L.circle(center, {
|
||||||
|
radius: circleRadiusMRef.current,
|
||||||
|
color: tokens.accent,
|
||||||
|
weight: 1,
|
||||||
|
fillColor: tokens.accent,
|
||||||
|
fillOpacity: 0.05,
|
||||||
|
interactive: false,
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
markerRef.current = L.marker(center, {
|
||||||
|
icon: buildSubjectIcon(
|
||||||
|
L,
|
||||||
|
addressRef.current.trim() || "Адрес квартиры",
|
||||||
|
subjectAreaLabelRef.current,
|
||||||
|
),
|
||||||
|
interactive: false,
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
leafletMapRef.current = map;
|
||||||
|
setMapZoom(DEFAULT_MAP_ZOOM);
|
||||||
|
map.on("zoomend", () => setMapZoom(map.getZoom()));
|
||||||
|
})
|
||||||
|
.catch(() => setMapLoadError(true));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (map) map.remove();
|
||||||
|
leafletMapRef.current = null;
|
||||||
|
markerRef.current = null;
|
||||||
|
circleRef.current = null;
|
||||||
|
};
|
||||||
|
// hasCoords is the only plain dependency this effect reads directly — the
|
||||||
|
// live lat/lon/radius/address values come from refs (assigned every
|
||||||
|
// render above), which exhaustive-deps correctly treats as stable.
|
||||||
|
}, [hasCoords]);
|
||||||
|
|
||||||
|
// Move the existing map/marker/circle to a newly picked address without
|
||||||
|
// tearing the map down (avoids a tile-reload flash on every pick).
|
||||||
|
useEffect(() => {
|
||||||
|
const map = leafletMapRef.current;
|
||||||
|
if (!map || !coords) return;
|
||||||
|
const center: [number, number] = [coords.lat, coords.lon];
|
||||||
|
map.setView(center, map.getZoom());
|
||||||
|
markerRef.current?.setLatLng(center);
|
||||||
|
circleRef.current?.setLatLng(center);
|
||||||
|
}, [coords]);
|
||||||
|
|
||||||
|
// Sync the radius circle in place when the РАДИУС АНАЛИЗА selection changes.
|
||||||
|
useEffect(() => {
|
||||||
|
circleRef.current?.setRadius(circleRadiusM);
|
||||||
|
}, [circleRadiusM]);
|
||||||
|
|
||||||
|
// Refresh the pin's address/area caption in place as the user edits the form.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!markerRef.current) return;
|
||||||
|
const w = window as any;
|
||||||
|
if (!w.L) return;
|
||||||
|
markerRef.current.setIcon(
|
||||||
|
buildSubjectIcon(w.L, address.trim() || "Адрес квартиры", subjectAreaLabel),
|
||||||
|
);
|
||||||
|
}, [address, subjectAreaLabel]);
|
||||||
|
|
||||||
// Address combobox render state (M6). `popupOpen` = the dropdown is shown at
|
// Address combobox render state (M6). `popupOpen` = the dropdown is shown at
|
||||||
// all (incl. loading / empty notes); `listboxOpen` = it holds real selectable
|
// all (incl. loading / empty notes); `listboxOpen` = it holds real selectable
|
||||||
// options, which is when the input advertises aria-expanded + activedescendant.
|
// options, which is when the input advertises aria-expanded + activedescendant.
|
||||||
|
|
@ -870,7 +1078,12 @@ export default function ParamsPanel({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* MAP */}
|
{/* MAP — real Leaflet + OSM basemap, centred on the geocoded address
|
||||||
|
(Fix, audit #2264 C7 follow-up): was a decorative SVG (grid + fake
|
||||||
|
streets + a clamped best-effort radius ring), no real coordinates
|
||||||
|
behind it at all. © OpenStreetMap attribution comes from Leaflet's
|
||||||
|
own attribution control on the tile layer below (OSM tile licence
|
||||||
|
requirement) — no separate caption needed. */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "relative",
|
position: "relative",
|
||||||
|
|
@ -882,126 +1095,34 @@ export default function ParamsPanel({
|
||||||
flex: "0 0 auto",
|
flex: "0 0 auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* zoomable map content — scaled by the +/− controls. The map controls
|
{hasCoords ? (
|
||||||
and corner bracket below are SIBLINGS (outside this layer) so they
|
mapLoadError ? (
|
||||||
never scale. zoom=1 → scale() identity → pixel-identical to design. */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
transform: `scale(${zoom})`,
|
|
||||||
transformOrigin: "center",
|
|
||||||
transition: "transform .15s ease-out",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 440 210"
|
|
||||||
preserveAspectRatio="xMidYMid slice"
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
inset: 0,
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<rect width="440" height="210" fill="#e6edf4" />
|
|
||||||
<g stroke="#dbe5ef" strokeWidth={1}>
|
|
||||||
<path d="M0 38H440M0 78H440M0 118H440M0 158H440M0 198H440" />
|
|
||||||
<path d="M40 0V210M110 0V210M180 0V210M250 0V210M320 0V210M390 0V210" />
|
|
||||||
</g>
|
|
||||||
<g stroke="#f5f8fc" strokeWidth={8} strokeLinecap="round">
|
|
||||||
<path d="M-10 92 H450" />
|
|
||||||
<path d="M150 -10 V220" />
|
|
||||||
<path d="M-10 30 L260 0" />
|
|
||||||
</g>
|
|
||||||
<g stroke="#eef3f9" strokeWidth={5}>
|
|
||||||
<path d="M300 -10 L470 150" />
|
|
||||||
<path d="M-10 170 H450" />
|
|
||||||
</g>
|
|
||||||
<path d="M150 92 L470 200" stroke="#cfe0f3" strokeWidth={6} />
|
|
||||||
<g fill="none" stroke="#2e8bff" strokeDasharray="3 4">
|
|
||||||
<circle cx="208" cy="100" r="44" opacity={0.55} />
|
|
||||||
<circle cx="208" cy="100" r="80" opacity={0.4} />
|
|
||||||
<circle cx="208" cy="100" r={outerRingR} opacity={0.28} />
|
|
||||||
</g>
|
|
||||||
<circle cx="208" cy="100" r="80" fill="#2e8bff" opacity={0.04} />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{/* center pin */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: "47%",
|
|
||||||
top: "48%",
|
|
||||||
transform: "translate(-50%,-100%)",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 6,
|
justifyContent: "center",
|
||||||
background: tokens.surface.w85,
|
padding: "0 16px",
|
||||||
border: `1px solid ${tokens.accent}`,
|
textAlign: "center",
|
||||||
borderRadius: 4,
|
fontSize: 10.5,
|
||||||
padding: "4px 8px",
|
lineHeight: 1.4,
|
||||||
whiteSpace: "nowrap",
|
color: tokens.hint,
|
||||||
boxShadow: "0 3px 10px rgba(46,139,255,.25)",
|
fontFamily: tokens.font.sans,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
Не удалось загрузить карту. Проверьте интернет-соединение.
|
||||||
style={{
|
|
||||||
width: 7,
|
|
||||||
height: 7,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: tokens.accent,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* M10: the SUBJECT pin must show the subject's OWN address + area,
|
|
||||||
never an analog's. The form area (subjectAreaLabel) is rendered
|
|
||||||
here so an analog pin that lands near the centre can no longer be
|
|
||||||
mistaken for the subject's caption. */}
|
|
||||||
<span
|
|
||||||
style={{ display: "flex", flexDirection: "column", gap: 1 }}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: 11, fontWeight: 600 }}>
|
|
||||||
{address.trim() || "Адрес квартиры"}
|
|
||||||
</span>
|
|
||||||
{subjectAreaLabel && (
|
|
||||||
<span style={{ fontSize: 9, color: tokens.muted }}>
|
|
||||||
{subjectAreaLabel} · оцениваемая квартира
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
style={{
|
ref={mapContainerRef}
|
||||||
width: 0,
|
style={{ position: "absolute", inset: 0 }}
|
||||||
height: 0,
|
aria-label="Карта расположения квартиры"
|
||||||
borderLeft: "5px solid transparent",
|
|
||||||
borderRight: "5px solid transparent",
|
|
||||||
borderTop: `7px solid ${tokens.accent}`,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
{/* map controls — real Leaflet zoom (Leaflet's own zoomControl
|
||||||
|
is disabled above; these are the HUD-styled buttons). */}
|
||||||
{/* analog price pins — projected from the real estimate via mapMarkers().
|
|
||||||
Empty markers (no estimate yet) → no price pins, just the subject pin +
|
|
||||||
radius rings (Finding #2: never the fabricated fixture prices/dots). */}
|
|
||||||
{markers.map((m, i) => (
|
|
||||||
<div key={i} style={{ ...analogPin, left: m.left, top: m.top }}>
|
|
||||||
{m.label}
|
|
||||||
<br />
|
|
||||||
<span style={{ color: tokens.muted }}>{m.sub}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* map controls */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
|
|
@ -1015,6 +1136,7 @@ export default function ParamsPanel({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Приблизить карту"
|
aria-label="Приблизить карту"
|
||||||
|
disabled={mapZoom >= MAX_MAP_ZOOM}
|
||||||
style={{
|
style={{
|
||||||
width: 24,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
|
|
@ -1024,21 +1146,22 @@ export default function ParamsPanel({
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
cursor: "pointer",
|
cursor: mapZoom >= MAX_MAP_ZOOM ? "default" : "pointer",
|
||||||
transition: "all .15s",
|
transition: "all .15s",
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
color: tokens.muted,
|
color: tokens.muted,
|
||||||
|
opacity: mapZoom >= MAX_MAP_ZOOM ? 0.4 : 1,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
fontFamily: "inherit",
|
fontFamily: "inherit",
|
||||||
}}
|
}}
|
||||||
onClick={() => setZoom((z) => Math.min(2.5, z + 0.25))}
|
onClick={() => leafletMapRef.current?.zoomIn()}
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Отдалить карту"
|
aria-label="Отдалить карту"
|
||||||
disabled={zoom <= 1}
|
disabled={mapZoom <= MIN_MAP_ZOOM}
|
||||||
style={{
|
style={{
|
||||||
width: 24,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
|
|
@ -1048,23 +1171,66 @@ export default function ParamsPanel({
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
cursor: zoom <= 1 ? "default" : "pointer",
|
cursor: mapZoom <= MIN_MAP_ZOOM ? "default" : "pointer",
|
||||||
transition: "all .15s",
|
transition: "all .15s",
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
color: tokens.muted,
|
color: tokens.muted,
|
||||||
opacity: zoom <= 1 ? 0.4 : 1,
|
opacity: mapZoom <= MIN_MAP_ZOOM ? 0.4 : 1,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
fontFamily: "inherit",
|
fontFamily: "inherit",
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => leafletMapRef.current?.zoomOut()}
|
||||||
if (zoom > 1) setZoom((z) => Math.max(1, z - 0.25));
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
−
|
−
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
/* No coordinates yet (address not typed/picked from suggest) — a
|
||||||
|
tidy placeholder, never an empty grey box or a broken map. */
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 6,
|
||||||
|
padding: "0 16px",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 21s-7-7.2-7-12a7 7 0 1 1 14 0c0 4.8-7 12-7 12Z"
|
||||||
|
stroke={tokens.muted2}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="9" r="2.5" stroke={tokens.muted2} strokeWidth={1.5} />
|
||||||
|
</svg>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10.5,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
color: tokens.hint,
|
||||||
|
fontFamily: tokens.font.sans,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Выберите адрес из подсказок, чтобы увидеть карту
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* map corner bracket */}
|
{/* map corner bracket — decorative HUD chrome, shown regardless of
|
||||||
|
map/placeholder state (unchanged from the prior design). */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
|
|
@ -1078,23 +1244,6 @@ export default function ParamsPanel({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Fix #7 (audit) — the SVG above is decorative (grid + fake streets +
|
|
||||||
radius rings), not a real map, and the rings themselves aren't drawn
|
|
||||||
to scale (outerRingR is a clamped best-effort, not a true geometric
|
|
||||||
projection of РАДИУС). Same honesty-caption tone as SourcesMap's
|
|
||||||
footer disclosure. */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 9,
|
|
||||||
color: tokens.hint,
|
|
||||||
lineHeight: 1.4,
|
|
||||||
marginTop: 6,
|
|
||||||
flex: "0 0 auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Схематично · не географическая карта · радиус не в масштабе
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* radius row */}
|
{/* radius row */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,10 @@ export const object: ObjectInfo = {
|
||||||
repair: "Хороший",
|
repair: "Хороший",
|
||||||
balcony: true,
|
balcony: true,
|
||||||
locationCoef: "0.87",
|
locationCoef: "0.87",
|
||||||
streetView: "Street View · май 2024",
|
// Approximate центр Екатеринбурга near ул. Малышева, 30 — illustrative
|
||||||
compass: "СЕВЕРО-ЗАПАД",
|
// fixture coordinate for the HeroBar locator mini-map (unwired usage only).
|
||||||
|
lat: 56.8384,
|
||||||
|
lon: 60.6057,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- 02 RESULT ------------------------------------------------------------
|
// ---- 02 RESULT ------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,6 @@
|
||||||
// (parseAddress). Backend should return structured address components.
|
// (parseAddress). Backend should return structured address components.
|
||||||
// BE-3 location coefficient shipped 2026-07-03 (#2045) and is wired here
|
// BE-3 location coefficient shipped 2026-07-03 (#2045) and is wired here
|
||||||
// (mapObject/mapLocation consume GET /trade-in/location-coef, #2317).
|
// (mapObject/mapLocation consume GET /trade-in/location-coef, #2317).
|
||||||
// street-view caption / compass bearing are still not in the API →
|
|
||||||
// remain placeholders.
|
|
||||||
//
|
//
|
||||||
// Enum <-> RU reconciliation (design dropdowns have options with no enum value):
|
// Enum <-> RU reconciliation (design dropdowns have options with no enum value):
|
||||||
// house type: 'Блочный' ⇄ enum 'other' (enum has no dedicated block type)
|
// house type: 'Блочный' ⇄ enum 'other' (enum has no dedicated block type)
|
||||||
|
|
@ -820,8 +818,10 @@ export function mapObject(
|
||||||
repair: e.repair_state ? REPAIR_RU[e.repair_state] : "—",
|
repair: e.repair_state ? REPAIR_RU[e.repair_state] : "—",
|
||||||
balcony: e.has_balcony ?? false,
|
balcony: e.has_balcony ?? false,
|
||||||
locationCoef: coefDeltaLabel(coef),
|
locationCoef: coefDeltaLabel(coef),
|
||||||
streetView: "", // TODO BE-3 (backend does not surface a street-view caption)
|
// Same target_lat/target_lon the ParamsPanel/SourcesMap map pins use —
|
||||||
compass: "", // TODO BE-3 (backend does not surface a compass bearing)
|
// null while the estimate has no geocode yet.
|
||||||
|
lat: e.target_lat,
|
||||||
|
lon: e.target_lon,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,11 @@ export interface ObjectInfo {
|
||||||
repair: string;
|
repair: string;
|
||||||
balcony: boolean;
|
balcony: boolean;
|
||||||
locationCoef: string;
|
locationCoef: string;
|
||||||
streetView: string;
|
// Subject coordinates for the HeroBar locator mini-map (Leaflet/OSM). null
|
||||||
compass: string;
|
// when the estimate has no geocode yet — the map then renders an honest
|
||||||
|
// "нет координат" placeholder instead of an empty/broken box.
|
||||||
|
lat: number | null;
|
||||||
|
lon: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 02 RESULT ------------------------------------------------------------
|
// ---- 02 RESULT ------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue