gendesign/tradein-mvp/backend/data/sql/187_web_support_chat.sql
bot-backend 7377fb61e5 feat(tradein/support): веб-чат поддержки поверх Telegram support-моста
Сайт закрыт Caddy basic_auth, тред привязывается к X-Authenticated-User (нет
анонимов). Новые web_support_threads/web_support_messages (миграция 187) —
отдельно от tg_support_* (186): у веб-клиента нет Telegram chat_id, смешение
identity-схем в одной таблице потребовало бы NULLABLE chat_id/username и XOR
CHECK-ограничений без реальной выгоды (обоснование в самой миграции).

API (app/api/v1/support.py, /api/v1/trade-in/support/*):
  POST /messages  — отправка (sendMessage-зеркало в топик, "[С САЙТА] user: ...")
  GET  /messages   — polling своего треда (?since=id)
  GET  /unread     — счётчик непрочитанного
  POST /read       — отметить прочитанным
Тред резолвится ИСКЛЮЧИТЕЛЬНО по username — нет параметра, которым можно
адресовать чужой тред (структурная защита от IDOR, не только access-check).

bridge.py: _handle_group_reply получил ветку резолва reply в web-тред (после
существующего tg-резолва, без изменения Telegram-пути) — оператор отвечает
одинаково, вне зависимости от канала клиента.

Rate-limit: новый SlidingWindowLimiter (ratelimit.py) — 12 msg/60s per user,
жёстче общего RateLimitMiddleware (общий бот-токен, флуд одного клиента иначе
бьёт по доставке всем).

Security: этот процесс (app/main.py) теперь тоже зовёт Telegram Bot API
напрямую (раньше — только изолированный tgbot_main.py) — реплицированы обе
защиты токена: httpx-INFO подавлен, include_local_variables=False +
redact_telegram_bot_token в sentry before_send.

Бот не сконфигурирован (пустой TELEGRAM_BOT_TOKEN/chat_id) → 503, не 500.
2026-07-26 22:58:54 +03:00

100 lines
8.5 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- 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'.
-- Коллизий между web_support_messages.topic_message_id и
-- tg_support_messages.topic_message_id НЕ возникает: оба — Telegram
-- message_id ОДНОЙ и той же support-супергруппы, а Telegram message_id
-- в пределах одного чата монотонно возрастает и никогда не переиспользуется
-- — значит конкретное значение окажется ровно в одной из двух таблиц.
-- - bridge.py меняется МИНИМАЛЬНО: _handle_group_reply получает одну
-- дополнительную ветку (пробуем tg-резолв, потом web-резолв, потом
-- existing orphan-warning) — существующий Telegram-путь не трогается.
--
-- ЧТО:
-- - 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.
--
-- IDEMPOTENCY: CREATE TABLE/INDEX IF NOT EXISTS — безопасный re-run.
-- Зависимости: нет (новые standalone таблицы, никакие существующие
-- tg_support_*/иные таблицы не трогаются).
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,
operator_tg_id bigint,
created_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE web_support_messages IS '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.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;