H1 (critical): reorder send_support_message — get_or_create_thread now runs AFTER a successful Telegram send, not before. Prod runs a single uvicorn process with no --workers on a sync SQLAlchemy engine sharing one event loop; holding a row-lock across the Telegram call (which can legally take minutes on 429/5xx worker-grade retries) risked freezing the entire API on a second concurrent request from the same user. send_message now also accepts explicit timeout/max_retries so the interactive endpoint uses a bounded budget instead of inheriting the long-polling worker's retry policy. M1: web_support_messages and tg_support_messages both gain a support_chat_id column (188 migration for the already-applied tg_support_messages table; 187 edited in place since it hasn't shipped yet). bridge._handle_group_reply now resolves BOTH the Telegram and web candidate under the CURRENT support_chat_id and refuses delivery loudly (logger.error) if both match, instead of silently preferring the Telegram path — closing a cross-table misroute risk that would surface if the support group is ever recreated. M2: a media reply to a web-mirrored message (including photos with a caption) is now refused in full with an explicit notice back in the topic, instead of silently delivering just the caption text or logging a WARNING nobody sees. M3: list_support_messages/get_support_unread/mark_support_read switched from async def to def — their bodies are pure sync psycopg calls; running them as async def executed blocking DB work directly on the event loop. M5: list_messages now takes a bounded LIMIT (last N, chronological) so a long-lived thread doesn't return its entire history on every poll. L1-L4: warn when Telegram doesn't return a message_id (routing dead-end), rate limit is peeked before send and only recorded on success (failed attempts no longer burn the budget), SlidingWindowLimiter gained the same empty-bucket cleanup as RateLimitMiddleware, and _require_username now strips whitespace so a proxy-injected space can't fork a second thread. L5: removed 187 from _manifest_applied.txt — the deploy pipeline tracks applied migrations via _schema_migrations, not this file, and keeping an unmerged migration name out of it preserves the option to rename before merge without tripping the "can't rename applied migrations" test.
122 lines
11 KiB
PL/PgSQL
122 lines
11 KiB
PL/PgSQL
-- 187_web_support_chat.sql
|
||
-- Web-чат поддержки (сайт МЕРА) поверх УЖЕ существующего Telegram support-моста
|
||
-- (data/sql/186_tg_support.sql, app/services/tgbot/bridge.py). Источник обращения
|
||
-- меняется (сайт вместо Telegram-лички клиента), маршрутизация ответа оператора
|
||
-- (реплай на зеркало в топике супергруппы) остаётся ТОЙ ЖЕ — оператор ничего
|
||
-- нового не учит.
|
||
--
|
||
-- ПОЧЕМУ ОТДЕЛЬНЫЕ ТАБЛИЦЫ, А НЕ "tg_support_* + channel"
|
||
-- (взвешено явно, per code review requirement):
|
||
--
|
||
-- Вариант А (отклонён) — добавить channel text ('telegram'|'web') в
|
||
-- tg_support_users/tg_support_messages:
|
||
-- - tg_support_users.chat_id bigint PRIMARY KEY — это Telegram private
|
||
-- chat id клиента. У веб-пользователя сайта ЕГО НЕТ (клиент никогда не
|
||
-- писал боту в личку) — пришлось бы либо (а) городить синтетический
|
||
-- chat_id для веб-юзера (напр. отрицательный hash от username) — это
|
||
-- вводит ВТОРУЮ систему идентификации внутри одной PK-колонки,
|
||
-- семантика которой документирована как "Telegram chat id" (186:54),
|
||
-- либо (б) делать chat_id NULLABLE и городить ещё одну колонку
|
||
-- username NULLABLE рядом — таблица с двумя взаимоисключающими
|
||
-- identity-схемами и кучей CHECK-ограничений вида
|
||
-- "chat_id XOR username NOT NULL".
|
||
-- - Доставка обратно ТОЖЕ разная: Telegram-путь шлёт copyMessage в личку
|
||
-- клиента, веб-путь просто пишет строку в БД (personal chat не
|
||
-- существует) — код в bridge.py и так ветвится по каналу, общая
|
||
-- таблица не убирает эту ветку, только добавляет NULL-поля.
|
||
-- - Риск регрессии: tg_support_* уже покрыты test_bridge.py (14+
|
||
-- кейсов) и работают в проде (PR #2526) — трогать рабочую, протестированную
|
||
-- схему ради ещё не запущенной фичи повышает blast radius без выгоды.
|
||
--
|
||
-- Вариант Б (выбран) — новые web_support_threads/web_support_messages:
|
||
-- - Идентификатор клиента — username (X-Authenticated-User, сайт закрыт
|
||
-- Caddy basic_auth, публичного доступа нет — см. app/main.py rbac_guard)
|
||
-- — чистый, не smoke-и-зеркала не переиспользующий Telegram identity.
|
||
-- - topic_message_id-маршрутизация (ключевой механизм моста) СОХРАНЕНА
|
||
-- 1-в-1 по конвенции 186: partial UNIQUE на topic_message_id,
|
||
-- заполняется только для direction='in', NULL для direction='out'.
|
||
-- - bridge.py меняется МИНИМАЛЬНО: _handle_group_reply получает одну
|
||
-- дополнительную ветку (резолвит tg-путь И web-путь, потом
|
||
-- 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_messages — лог переписки, direction='in' (от юзера) |
|
||
-- 'out' (ответ оператора, реплай из bridge.py).
|
||
--
|
||
-- 152-ФЗ:
|
||
-- Переписка (text_body) — ПДн (может содержать любые данные, которые юзер
|
||
-- решит написать). ON DELETE CASCADE от web_support_threads делает erasure
|
||
-- ОДНОЙ операцией (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.
|
||
-- Зависимости: нет (новые standalone таблицы, никакие существующие
|
||
-- tg_support_*/иные таблицы не трогаются — см. 188 для ALTER на tg_support_messages).
|
||
|
||
BEGIN;
|
||
|
||
CREATE TABLE IF NOT EXISTS web_support_threads (
|
||
id bigserial PRIMARY KEY,
|
||
username text NOT NULL UNIQUE,
|
||
created_at timestamptz NOT NULL DEFAULT now(),
|
||
last_seen_at timestamptz NOT NULL DEFAULT now(),
|
||
last_read_at timestamptz NOT NULL DEFAULT now()
|
||
);
|
||
|
||
COMMENT ON TABLE web_support_threads IS '152-ФЗ: одна строка на username (сайт МЕРА, X-Authenticated-User) — единый тред переписки с поддержкой через веб-чат. Удаление клиента — DELETE FROM web_support_threads WHERE username=...; ON DELETE CASCADE в web_support_messages подчищает переписку одной операцией.';
|
||
COMMENT ON COLUMN web_support_threads.username IS 'X-Authenticated-User (Caddy basic_auth) — сайт закрыт, анонимов нет, см. app/main.py rbac_guard.';
|
||
COMMENT ON COLUMN web_support_threads.last_seen_at IS 'Обновляется при отправке юзером нового сообщения (send-активность, НЕ на чтение истории).';
|
||
COMMENT ON COLUMN web_support_threads.last_read_at IS 'Отметка "прочитано до" (POST /api/v1/trade-in/support/read) — используется для счётчика непрочитанного (GET /support/unread).';
|
||
|
||
CREATE TABLE IF NOT EXISTS web_support_messages (
|
||
id bigserial PRIMARY KEY,
|
||
thread_id bigint NOT NULL REFERENCES web_support_threads (id) ON DELETE CASCADE,
|
||
direction text NOT NULL CHECK (direction IN ('in', 'out')),
|
||
text_body text NOT NULL CHECK (char_length(btrim(text_body)) > 0),
|
||
topic_message_id bigint,
|
||
support_chat_id bigint,
|
||
operator_tg_id bigint,
|
||
created_at timestamptz NOT NULL DEFAULT now()
|
||
);
|
||
|
||
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.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.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''.';
|
||
|
||
CREATE UNIQUE INDEX IF NOT EXISTS web_support_messages_topic_message_id_uq
|
||
ON web_support_messages (topic_message_id)
|
||
WHERE topic_message_id IS NOT NULL;
|
||
|
||
CREATE INDEX IF NOT EXISTS web_support_messages_thread_id_created_at_idx
|
||
ON web_support_messages (thread_id, created_at DESC);
|
||
|
||
COMMIT;
|