Сайт закрыт 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.
100 lines
8.5 KiB
PL/PgSQL
100 lines
8.5 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'.
|
||
-- Коллизий между 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;
|