diff --git a/migrations/0006_support_tickets.sql b/migrations/0006_support_tickets.sql new file mode 100644 index 0000000..111bdf2 --- /dev/null +++ b/migrations/0006_support_tickets.sql @@ -0,0 +1,41 @@ +-- ===================================================================== +-- ТЕХПОДДЕРЖКА: роль Support + тикеты через Telegram-бота +-- ===================================================================== +-- Единственный канал связи с поддержкой — бот (см. src/bot.rs). Кнопка +-- "Поддержка" переводит юзера в разовый режим ожидания сообщения +-- (awaiting_support_message), сбрасывается сразу после получения текста — +-- случайное сообщение мимо кнопки тикет не создаёт. +ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check; +ALTER TABLE users + ADD CONSTRAINT users_role_check + CHECK (role IN ('user', 'owner', 'moderator', 'partner', 'support', 'admin')); + +ALTER TABLE users ADD COLUMN IF NOT EXISTS awaiting_support_message BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE TABLE IF NOT EXISTS support_tickets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closed')), + -- Информационно: кто последним ответил. Не enforced-назначение — любой + -- staff/support видит и отвечает на любой тикет. + assigned_to UUID REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_support_tickets_status ON support_tickets(status, updated_at DESC); +-- Быстро найти "есть ли уже открытый тикет у юзера" — частичный индекс, +-- т.к. это единственный поиск по user_id, который реально нужен в горячем пути. +CREATE INDEX IF NOT EXISTS idx_support_tickets_user_open ON support_tickets(user_id) + WHERE status = 'open'; + +CREATE TABLE IF NOT EXISTS support_messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + ticket_id UUID NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE, + sender_role VARCHAR(10) NOT NULL CHECK (sender_role IN ('user', 'staff')), + sender_user_id UUID REFERENCES users(id), + body TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_support_messages_ticket ON support_messages(ticket_id, created_at); diff --git a/src/bot.rs b/src/bot.rs index 06bca3b..622f8d4 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -8,7 +8,8 @@ use crate::modules::{ auth::service::AuthService, billing::service::BillingService, - referrals::service::ReferralService, users::service::UserService, + referrals::service::ReferralService, support::service::SupportService, + users::service::UserService, }; use teloxide::{ prelude::*, @@ -40,6 +41,7 @@ pub async fn run_bot( users: UserService, billing: BillingService, referrals: ReferralService, + support: SupportService, cancel_token: CancellationToken, ) { let config = BotConfig { @@ -50,18 +52,27 @@ pub async fn run_bot( .expect("КРИТИЧЕСКАЯ ОШИБКА: WEB_APP_BASE_URL не задан!"), }; - let handler = Update::filter_message().branch( - dptree::entry() - .filter_command::() - .endpoint(handle_commands), - ); + // Ветка команд (/start, /menu) — первая. Всё, что не распозналось как + // команда, падает во вторую ветку: там же живёт разовый захват + // свободного текста для тикетов поддержки (см. handle_free_text) — + // единственный канал создания тикета, кнопка "Поддержка" выставляет + // User::awaiting_support_message перед этим. + let handler = Update::filter_message() + .branch( + dptree::entry() + .filter_command::() + .endpoint(handle_commands), + ) + .branch(dptree::entry().endpoint(handle_free_text)); let callback_handler = Update::filter_callback_query().endpoint(handle_callbacks); let mut dispatcher = Dispatcher::builder( bot, dptree::entry().branch(handler).branch(callback_handler), ) - .dependencies(dptree::deps![auth, users, billing, referrals, config]) + .dependencies(dptree::deps![ + auth, users, billing, referrals, support, config + ]) .build(); tokio::select! { @@ -70,6 +81,52 @@ pub async fn run_bot( } } +/// Свободный текст вне команд/кнопок. Единственное осмысленное действие — +/// сообщение в поддержку, и только когда юзер только что нажал кнопку +/// "Поддержка" (см. `menu_support` в `handle_callbacks`) — иначе (как и +/// раньше, до этого хендлера) молча игнорируется, никакой регрессии для +/// случайных сообщений мимо кнопки. +#[instrument(skip(bot, users, support), fields(chat_id = msg.chat.id.0))] +async fn handle_free_text( + bot: Bot, + msg: Message, + users: UserService, + support: SupportService, +) -> ResponseResult<()> { + let Some(text) = msg.text() else { + return Ok(()); + }; + let tg_id = msg.chat.id.0; + + let user = match users.get_user_profile(tg_id).await { + Ok(Some(u)) => u, + _ => return Ok(()), + }; + + if !user.awaiting_support_message { + return Ok(()); + } + + match support.handle_user_message(user.id, text).await { + Ok(()) => { + bot.send_message( + msg.chat.id, + "✅ Сообщение передано в поддержку. Мы ответим здесь же.", + ) + .await?; + } + Err(e) => { + tracing::error!(error = ?e, tg_id, "Не удалось сохранить сообщение в поддержку"); + bot.send_message( + msg.chat.id, + "Не удалось отправить сообщение, попробуйте ещё раз.", + ) + .await?; + } + } + Ok(()) +} + #[instrument(skip(bot, auth), fields(chat_id = msg.chat.id.0, username = ?msg.chat.username()))] async fn handle_commands( bot: Bot, @@ -139,7 +196,8 @@ async fn handle_commands( Ok(()) } -#[instrument(skip(bot, auth, users, billing, referrals, config), fields(user_id = q.from.id.0))] +#[allow(clippy::too_many_arguments)] +#[instrument(skip(bot, auth, users, billing, referrals, support, config), fields(user_id = q.from.id.0))] async fn handle_callbacks( bot: Bot, q: CallbackQuery, @@ -147,6 +205,7 @@ async fn handle_callbacks( users: UserService, billing: BillingService, referrals: ReferralService, + support: SupportService, config: BotConfig, ) -> ResponseResult<()> { if let (Some(data), Some(msg)) = (q.data, q.message) { @@ -378,6 +437,35 @@ async fn handle_callbacks( ("Ошибка связи с ядром системы.".into(), back_keyboard()) } }, + // Единственный канал связи с поддержкой — переводит юзера в + // разовый режим ожидания (см. handle_free_text), следующее его + // текстовое сообщение станет тикетом/ответом в уже открытый. + "menu_support" => match users.get_user_profile(tg_id).await { + Ok(Some(u)) => { + if let Err(e) = support.set_awaiting(u.id).await { + tracing::error!(error = ?e, tg_id, "Не удалось выставить awaiting_support_message"); + } + let mut text = "🆘 ПОДДЕРЖКА\n\nОпишите проблему одним сообщением — мы ответим здесь же.".to_string(); + if let Ok(Some((_, messages))) = support.get_open_ticket_thread_for_user(u.id).await { + if !messages.is_empty() { + text.push_str("\n\nТекущий тикет:"); + for m in messages.iter().rev().take(3).rev() { + let sender = if m.sender_role == "staff" { "Поддержка" } else { "Вы" }; + text.push_str(&format!("\n\n{}: {}", sender, m.body)); + } + } + } + (text, back_keyboard()) + } + Ok(None) => ( + "Оперативник не найден в базе данных.".into(), + back_keyboard(), + ), + Err(e) => { + tracing::error!(error = ?e, tg_id, "КРИТИЧЕСКАЯ ОШИБКА при загрузке профиля для поддержки"); + ("Ошибка связи с ядром системы.".into(), back_keyboard()) + } + }, // Код для входа в десктоп/мобильное приложение — см. докстринг // AuthService::create_bot_login_code про то, почему это надёжнее // deep-link-варианта (`?start=`). @@ -470,6 +558,10 @@ fn main_menu_keyboard() -> InlineKeyboardMarkup { "🔑 Код для входа в приложение", "menu_app_login", )], + vec![InlineKeyboardButton::callback( + "🆘 Поддержка", + "menu_support", + )], ]) } fn profile_keyboard() -> InlineKeyboardMarkup { diff --git a/src/db/models.rs b/src/db/models.rs index 464a281..e21eb7e 100644 --- a/src/db/models.rs +++ b/src/db/models.rs @@ -38,6 +38,45 @@ pub struct User { /// Партнёр, чей диплинк привёл этого пользователя (проставляется один /// раз при регистрации, first-wins — см. `set_referred_by_partner`). pub referred_by_partner_id: Option, + /// Разовый флаг "юзер нажал кнопку Поддержка в боте, следующее его + /// текстовое сообщение — тикет/ответ в тикет". Сбрасывается сразу после + /// получения текста (см. `support::service::SupportService::handle_user_message`). + pub awaiting_support_message: bool, +} + +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct SupportTicket { + pub id: Uuid, + pub user_id: Uuid, + pub status: String, + pub assigned_to: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct SupportMessage { + pub id: Uuid, + pub ticket_id: Uuid, + pub sender_role: String, + pub sender_user_id: Option, + pub body: String, + pub created_at: DateTime, +} + +/// Для списка тикетов в админке — один JOIN-запрос вместо N+1 +/// (идентичность юзера + превью последнего сообщения). +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct SupportTicketSummary { + pub id: Uuid, + pub user_id: Uuid, + pub tg_id: Option, + pub tg_username: Option, + pub status: String, + pub message_count: i64, + pub last_message_preview: Option, + pub created_at: DateTime, + pub updated_at: DateTime, } #[derive(Debug, Clone, FromRow, Serialize)] diff --git a/src/db/repository.rs b/src/db/repository.rs index 6581b0c..6d60857 100644 --- a/src/db/repository.rs +++ b/src/db/repository.rs @@ -241,6 +241,36 @@ pub trait AppRepository: Send + Sync { processed_by: Uuid, ) -> Result, sqlx::Error>; + // --- ТЕХПОДДЕРЖКА (ТИКЕТЫ ЧЕРЕЗ БОТА) --- + /// Разовый флаг "юзер нажал кнопку Поддержка, следующее сообщение — тикет". + async fn set_awaiting_support(&self, user_id: Uuid, value: bool) -> Result<(), sqlx::Error>; + async fn get_open_ticket_for_user( + &self, + user_id: Uuid, + ) -> Result, sqlx::Error>; + async fn create_support_ticket(&self, user_id: Uuid) -> Result; + /// Добавляет сообщение и бампает `support_tickets.updated_at` в одной транзакции. + async fn add_support_message( + &self, + ticket_id: Uuid, + sender_role: &str, + sender_user_id: Option, + body: &str, + ) -> Result; + /// `status_filter` — `None` = все тикеты (open первыми, затем по дате). + async fn list_tickets( + &self, + status_filter: Option<&str>, + ) -> Result, sqlx::Error>; + async fn get_ticket(&self, id: Uuid) -> Result, sqlx::Error>; + async fn get_ticket_messages( + &self, + ticket_id: Uuid, + ) -> Result, sqlx::Error>; + async fn close_ticket(&self, id: Uuid) -> Result, sqlx::Error>; + /// Информационно: кто последним ответил — не enforced-назначение. + async fn set_ticket_assigned(&self, id: Uuid, staff_id: Uuid) -> Result<(), sqlx::Error>; + // --- VPN УЗЛЫ (CONTROL PLANE) --- /// Ноды в статусе `online` — то, что видят обычные юзеры (`/nodes`, `/routing`, `/stats`). async fn get_active_nodes(&self) -> Result, sqlx::Error>; @@ -294,7 +324,7 @@ impl AppRepository for PgRepository { ON CONFLICT (tg_id) DO UPDATE SET tg_username = EXCLUDED.tg_username \ RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \ data_limit_bytes, data_used_bytes, data_period_reset_at, \ - username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id" + username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message" ) .bind(Uuid::new_v4()).bind(tg_id).bind(username).fetch_one(&self.pool).await } @@ -304,7 +334,7 @@ impl AppRepository for PgRepository { sqlx::query_as::<_, User>( "SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \ data_limit_bytes, data_used_bytes, data_period_reset_at, \ - username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id \ + username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message \ FROM users WHERE id = $1" ) .bind(id) @@ -317,7 +347,7 @@ impl AppRepository for PgRepository { sqlx::query_as::<_, User>( "SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \ data_limit_bytes, data_used_bytes, data_period_reset_at, \ - username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id \ + username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message \ FROM users WHERE tg_id = $1" ) .bind(tg_id) @@ -331,7 +361,7 @@ impl AppRepository for PgRepository { "INSERT INTO users (id, seed_hash) VALUES ($1, $2) \ RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \ data_limit_bytes, data_used_bytes, data_period_reset_at, \ - username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id" + username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message" ) .bind(Uuid::new_v4()).bind(seed_hash).fetch_one(&self.pool).await } @@ -341,7 +371,7 @@ impl AppRepository for PgRepository { sqlx::query_as::<_, User>( "SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \ data_limit_bytes, data_used_bytes, data_period_reset_at, \ - username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id \ + username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message \ FROM users WHERE seed_hash = $1" ) .bind(hash) @@ -376,7 +406,7 @@ impl AppRepository for PgRepository { "UPDATE users SET data_limit_bytes = $1 WHERE id = $2 \ RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \ data_limit_bytes, data_used_bytes, data_period_reset_at, \ - username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id", + username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message", ) .bind(limit_bytes) .bind(user_id) @@ -965,7 +995,7 @@ impl AppRepository for PgRepository { VALUES ($1, $2, $3, $4, $5, $6, $7) \ RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \ data_limit_bytes, data_used_bytes, data_period_reset_at, \ - username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id" + username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message" ) .bind(Uuid::new_v4()) .bind(username) @@ -983,7 +1013,7 @@ impl AppRepository for PgRepository { sqlx::query_as::<_, User>( "SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \ data_limit_bytes, data_used_bytes, data_period_reset_at, \ - username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id \ + username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message \ FROM users WHERE username = $1" ) .bind(username) @@ -1001,7 +1031,7 @@ impl AppRepository for PgRepository { "UPDATE users SET can_manage_nodes = $1 WHERE id = $2 \ RETURNING id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \ data_limit_bytes, data_used_bytes, data_period_reset_at, \ - username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id" + username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message" ) .bind(enabled) .bind(user_id) @@ -1013,12 +1043,12 @@ impl AppRepository for PgRepository { async fn list_staff_users(&self, role_filter: Option<&str>) -> Result, sqlx::Error> { let roles: Vec<&str> = match role_filter { Some(r) => vec![r], - None => vec!["owner", "moderator", "partner"], + None => vec!["owner", "moderator", "partner", "support"], }; sqlx::query_as::<_, User>( "SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \ data_limit_bytes, data_used_bytes, data_period_reset_at, \ - username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id \ + username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message \ FROM users WHERE role = ANY($1) ORDER BY role, username" ) .bind(&roles) @@ -1031,7 +1061,7 @@ impl AppRepository for PgRepository { sqlx::query_as::<_, User>( "SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \ data_limit_bytes, data_used_bytes, data_period_reset_at, \ - username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id \ + username, password_hash, can_manage_nodes, commission_percent, partner_code, referred_by_partner_id, awaiting_support_message \ FROM users WHERE partner_code = $1 AND role = 'partner'" ) .bind(code) @@ -1303,6 +1333,148 @@ impl AppRepository for PgRepository { .await } + // ===================================================================== + // ТЕХПОДДЕРЖКА (ТИКЕТЫ ЧЕРЕЗ БОТА) + // ===================================================================== + + #[instrument(skip(self))] + async fn set_awaiting_support(&self, user_id: Uuid, value: bool) -> Result<(), sqlx::Error> { + sqlx::query("UPDATE users SET awaiting_support_message = $1 WHERE id = $2") + .bind(value) + .bind(user_id) + .execute(&self.pool) + .await?; + Ok(()) + } + + #[instrument(skip(self))] + async fn get_open_ticket_for_user( + &self, + user_id: Uuid, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, SupportTicket>( + "SELECT id, user_id, status, assigned_to, created_at, updated_at \ + FROM support_tickets WHERE user_id = $1 AND status = 'open' \ + ORDER BY created_at DESC LIMIT 1", + ) + .bind(user_id) + .fetch_optional(&self.pool) + .await + } + + #[instrument(skip(self))] + async fn create_support_ticket(&self, user_id: Uuid) -> Result { + sqlx::query_as::<_, SupportTicket>( + "INSERT INTO support_tickets (id, user_id) VALUES ($1, $2) \ + RETURNING id, user_id, status, assigned_to, created_at, updated_at", + ) + .bind(Uuid::new_v4()) + .bind(user_id) + .fetch_one(&self.pool) + .await + } + + #[instrument(skip(self))] + async fn add_support_message( + &self, + ticket_id: Uuid, + sender_role: &str, + sender_user_id: Option, + body: &str, + ) -> Result { + let mut tx = self.pool.begin().await?; + + let msg = sqlx::query_as::<_, SupportMessage>( + "INSERT INTO support_messages (id, ticket_id, sender_role, sender_user_id, body) \ + VALUES ($1, $2, $3, $4, $5) \ + RETURNING id, ticket_id, sender_role, sender_user_id, body, created_at", + ) + .bind(Uuid::new_v4()) + .bind(ticket_id) + .bind(sender_role) + .bind(sender_user_id) + .bind(body) + .fetch_one(&mut *tx) + .await?; + + sqlx::query("UPDATE support_tickets SET updated_at = NOW() WHERE id = $1") + .bind(ticket_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(msg) + } + + #[instrument(skip(self))] + async fn list_tickets( + &self, + status_filter: Option<&str>, + ) -> Result, sqlx::Error> { + let query = "SELECT t.id, t.user_id, u.tg_id, u.tg_username, t.status, \ + COUNT(m.id)::BIGINT AS message_count, \ + (SELECT body FROM support_messages \ + WHERE ticket_id = t.id ORDER BY created_at DESC LIMIT 1) AS last_message_preview, \ + t.created_at, t.updated_at \ + FROM support_tickets t \ + JOIN users u ON u.id = t.user_id \ + LEFT JOIN support_messages m ON m.ticket_id = t.id \ + WHERE ($1::TEXT IS NULL OR t.status = $1) \ + GROUP BY t.id, u.tg_id, u.tg_username \ + ORDER BY (t.status = 'open') DESC, t.updated_at DESC"; + sqlx::query_as::<_, SupportTicketSummary>(query) + .bind(status_filter) + .fetch_all(&self.pool) + .await + } + + #[instrument(skip(self))] + async fn get_ticket(&self, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, SupportTicket>( + "SELECT id, user_id, status, assigned_to, created_at, updated_at \ + FROM support_tickets WHERE id = $1", + ) + .bind(id) + .fetch_optional(&self.pool) + .await + } + + #[instrument(skip(self))] + async fn get_ticket_messages( + &self, + ticket_id: Uuid, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, SupportMessage>( + "SELECT id, ticket_id, sender_role, sender_user_id, body, created_at \ + FROM support_messages WHERE ticket_id = $1 ORDER BY created_at ASC", + ) + .bind(ticket_id) + .fetch_all(&self.pool) + .await + } + + #[instrument(skip(self))] + async fn close_ticket(&self, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, SupportTicket>( + "UPDATE support_tickets SET status = 'closed', updated_at = NOW() \ + WHERE id = $1 \ + RETURNING id, user_id, status, assigned_to, created_at, updated_at", + ) + .bind(id) + .fetch_optional(&self.pool) + .await + } + + #[instrument(skip(self))] + async fn set_ticket_assigned(&self, id: Uuid, staff_id: Uuid) -> Result<(), sqlx::Error> { + sqlx::query("UPDATE support_tickets SET assigned_to = $1 WHERE id = $2") + .bind(staff_id) + .bind(id) + .execute(&self.pool) + .await?; + Ok(()) + } + // ===================================================================== // VPN УЗЛЫ (CONTROL PLANE) // ===================================================================== diff --git a/src/main.rs b/src/main.rs index 0a496f5..724a061 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,7 +18,8 @@ use netrunner_control_plane::bot; use netrunner_control_plane::db::repository::{AppRepository, PgRepository}; use netrunner_control_plane::modules::{ auth::service::AuthService, billing::service::BillingService, nodes::service::NodeService, - referrals::service::ReferralService, staff::service::StaffService, users::service::UserService, + referrals::service::ReferralService, staff::service::StaffService, + support::service::SupportService, users::service::UserService, }; use netrunner_control_plane::tasks; @@ -296,6 +297,7 @@ async fn main() -> Result<(), Box> { referral_service: ReferralService::new(repo.clone()), user_service: UserService::new(repo.clone()), staff_service: StaffService::new(repo.clone()), + support_service: SupportService::new(repo.clone()), jwt_secret, bot_token: bot_token.clone(), bot_username, @@ -323,6 +325,7 @@ async fn main() -> Result<(), Box> { let bot_users = state.user_service.clone(); let bot_billing = state.billing_service.clone(); let bot_referrals = state.referral_service.clone(); + let bot_support = state.support_service.clone(); let bot_token_for_task = bot_token.clone(); let bot_handle = tokio::spawn(async move { @@ -334,6 +337,7 @@ async fn main() -> Result<(), Box> { bot_users, bot_billing, bot_referrals, + bot_support, bot_cancel_token, ) .await; diff --git a/src/modules/auth/guard.rs b/src/modules/auth/guard.rs index 0579fa5..876713d 100644 --- a/src/modules/auth/guard.rs +++ b/src/modules/auth/guard.rs @@ -157,6 +157,21 @@ pub async fn node_manage_guard( } } +/// Owner, Moderator ИЛИ Support — доступ к тикетам техподдержки. Шире +/// `staff_guard` (тот не пускает Support). +pub async fn support_access_guard( + State(_state): State, + req: Request, + next: Next, +) -> Result { + let user = current_user(&req)?; + if user.role == "owner" || user.role == "moderator" || user.role == "support" { + Ok(next.run(req).await) + } else { + Err(deny(user)) + } +} + /// Только Partner. pub async fn partner_guard( State(_state): State, @@ -205,6 +220,7 @@ mod tests { referral_service: ReferralService::new(repo.clone()), user_service: UserService::new(repo.clone()), staff_service: StaffService::new(repo.clone()), + support_service: crate::modules::support::service::SupportService::new(repo.clone()), jwt_secret: "test-secret".into(), bot_token: "test-bot-token".into(), bot_username: "test_bot".into(), @@ -235,6 +251,7 @@ mod tests { commission_percent: None, partner_code: None, referred_by_partner_id: None, + awaiting_support_message: false, } } @@ -360,4 +377,54 @@ mod tests { StatusCode::UNAUTHORIZED ); } + + #[sqlx::test] + async fn test_support_access_guard_matrix(pool: PgPool) { + let state = build_state(pool); + assert_eq!( + status_for( + state.clone(), + make_user("owner", false), + support_access_guard + ) + .await, + StatusCode::OK + ); + assert_eq!( + status_for( + state.clone(), + make_user("moderator", false), + support_access_guard + ) + .await, + StatusCode::OK + ); + assert_eq!( + status_for( + state.clone(), + make_user("support", false), + support_access_guard + ) + .await, + StatusCode::OK + ); + assert_eq!( + status_for( + state.clone(), + make_user("partner", false), + support_access_guard + ) + .await, + StatusCode::UNAUTHORIZED + ); + assert_eq!( + status_for( + state.clone(), + make_user("user", false), + support_access_guard + ) + .await, + StatusCode::UNAUTHORIZED + ); + } } diff --git a/src/modules/auth/mod.rs b/src/modules/auth/mod.rs index 2e37468..dbd8f65 100644 --- a/src/modules/auth/mod.rs +++ b/src/modules/auth/mod.rs @@ -1,10 +1,13 @@ //! Авторизация: Telegram Login Widget, анонимный "Ghost Protocol" (вход по seed-фразе), -//! логин Owner/Moderator/Partner по паролю, и ролевые JWT-гварды (`auth_guard` + -//! `owner_guard`/`staff_guard`/`node_manage_guard`/`partner_guard`). См. README.md рядом. +//! логин Owner/Moderator/Partner/Support по паролю, и ролевые JWT-гварды (`auth_guard` + +//! `owner_guard`/`staff_guard`/`node_manage_guard`/`partner_guard`/`support_access_guard`). +//! См. README.md рядом. pub mod controller; pub mod guard; pub mod service; -pub use guard::{auth_guard, node_manage_guard, owner_guard, partner_guard, staff_guard}; +pub use guard::{ + auth_guard, node_manage_guard, owner_guard, partner_guard, staff_guard, support_access_guard, +}; pub use service::{AuthService, Claims}; diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 44ac638..74a7afa 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -7,4 +7,5 @@ pub mod nodes; pub mod proxy_internal; pub mod referrals; pub mod staff; +pub mod support; pub mod users; diff --git a/src/modules/staff/service.rs b/src/modules/staff/service.rs index 5e97c0c..7014fd1 100644 --- a/src/modules/staff/service.rs +++ b/src/modules/staff/service.rs @@ -49,6 +49,16 @@ impl StaffService { .map_err(|e| map_create_error(e, "модератора")) } + /// Аккаунт саппорта — видит только тикеты (см. `support_access_guard`), + /// `can_manage_nodes`/`commission_percent`/`partner_code` неприменимы. + pub async fn create_support(&self, username: &str, password: &str) -> Result { + let hash = AuthService::hash_password(password)?; + self.repo + .create_staff_user(username, &hash, "support", false, None, None) + .await + .map_err(|e| map_create_error(e, "саппорта")) + } + pub async fn create_partner( &self, username: &str, diff --git a/src/modules/support/controller.rs b/src/modules/support/controller.rs new file mode 100644 index 0000000..2aef5f2 --- /dev/null +++ b/src/modules/support/controller.rs @@ -0,0 +1,103 @@ +//! HTTP-роуты тикетов техподдержки. Все — под `support_access_guard` +//! (Owner/Moderator/Support), см. `modules::auth::support_access_guard`. + +use axum::{ + extract::{Extension, Path, Query, State}, + middleware, + routing::{get, patch, post}, + Json, Router, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use uuid::Uuid; + +use crate::{ + api::ApiState, + db::models::User, + error::AppError, + modules::auth::{auth_guard, support_access_guard}, +}; + +pub fn router(state: ApiState) -> Router { + Router::new() + .route("/tickets", get(list_tickets)) + .route("/tickets/{id}", get(get_ticket)) + .route("/tickets/{id}/reply", post(reply)) + .route("/tickets/{id}/close", patch(close)) + .route_layer(middleware::from_fn_with_state( + state.clone(), + support_access_guard, + )) + .route_layer(middleware::from_fn_with_state(state, auth_guard)) +} + +#[derive(Deserialize)] +pub struct ListTicketsQuery { + pub status: Option, +} + +async fn list_tickets( + State(state): State, + Query(q): Query, +) -> Result, AppError> { + let status_filter = match q.status.as_deref() { + Some("all") | None => None, + Some(s) => Some(s), + }; + let tickets = state.support_service.list_tickets(status_filter).await?; + Ok(Json(json!(tickets))) +} + +async fn get_ticket( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + let (ticket, messages) = state + .support_service + .get_ticket_with_messages(id) + .await? + .ok_or_else(|| AppError::NotFound("Тикет не найден".into()))?; + Ok(Json(json!({ + "ticket": ticket, + "messages": messages, + }))) +} + +#[derive(Deserialize)] +pub struct ReplyPayload { + pub text: String, +} + +async fn reply( + Extension(staff): Extension, + State(state): State, + Path(id): Path, + Json(p): Json, +) -> Result, AppError> { + let ticket = state + .repo + .get_ticket(id) + .await? + .ok_or_else(|| AppError::NotFound("Тикет не найден".into()))?; + let ticket_user = state.repo.get_user_by_id(ticket.user_id).await?; + + state + .support_service + .reply_as_staff( + id, + staff.id, + &p.text, + &state.bot_token, + ticket_user.and_then(|u| u.tg_id), + ) + .await?; + Ok(Json(json!({ "ok": true }))) +} + +async fn close( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + let ticket = state.support_service.close_ticket(id).await?; + Ok(Json(json!(ticket))) +} diff --git a/src/modules/support/mod.rs b/src/modules/support/mod.rs new file mode 100644 index 0000000..6ae7d39 --- /dev/null +++ b/src/modules/support/mod.rs @@ -0,0 +1,8 @@ +//! Тикеты техподдержки — единственный канал создания: кнопка "Поддержка" в +//! Telegram-боте (см. `crate::bot`). Ответ саппорта — из веб-админки, +//! доставляется юзеру обратно в тот же Telegram-чат. См. `service.rs`. + +pub mod controller; +pub mod service; + +pub use service::SupportService; diff --git a/src/modules/support/service.rs b/src/modules/support/service.rs new file mode 100644 index 0000000..d60b0d0 --- /dev/null +++ b/src/modules/support/service.rs @@ -0,0 +1,268 @@ +//! Бизнес-логика тикетов техподдержки. Единственный канал создания +//! тикета/сообщения — бот (`bot.rs`), см. `handle_user_message`; ответ +//! саппорта приходит из веб-админки (`support::controller::reply`) и +//! доставляется юзеру обратно в тот же Telegram-чат через `reply_as_staff`. + +use crate::{ + db::models::{SupportMessage, SupportTicket, SupportTicketSummary}, + db::repository::AppRepository, + error::AppError, +}; +use std::sync::Arc; +use teloxide::{payloads::SendMessageSetters, prelude::Requester, types::ChatId, Bot}; +use uuid::Uuid; + +#[derive(Clone)] +pub struct SupportService { + repo: Arc, +} + +impl SupportService { + pub fn new(repo: Arc) -> Self { + Self { repo } + } + + /// Кнопка "Поддержка" в боте — переводит юзера в разовый режим ожидания + /// сообщения. Сбрасывается сам в `handle_user_message`. + pub async fn set_awaiting(&self, user_id: Uuid) -> Result<(), AppError> { + self.repo + .set_awaiting_support(user_id, true) + .await + .map_err(AppError::Database) + } + + /// Вызывается ботом ТОЛЬКО когда `user.awaiting_support_message == true` + /// (проверка — на вызывающей стороне, см. `bot.rs`). Переиспользует уже + /// открытый тикет юзера, если есть, иначе создаёт новый. + pub async fn handle_user_message(&self, user_id: Uuid, text: &str) -> Result<(), AppError> { + let ticket = match self + .repo + .get_open_ticket_for_user(user_id) + .await + .map_err(AppError::Database)? + { + Some(t) => t, + None => { + let t = self + .repo + .create_support_ticket(user_id) + .await + .map_err(AppError::Database)?; + metrics::counter!("support_tickets_total", "status" => "created").increment(1); + t + } + }; + + self.repo + .add_support_message(ticket.id, "user", Some(user_id), text) + .await + .map_err(AppError::Database)?; + + // Сбрасываем флаг только после успешной записи сообщения — если БД + // недоступна, следующее сообщение юзера не потеряется молча. + self.repo + .set_awaiting_support(user_id, false) + .await + .map_err(AppError::Database) + } + + pub async fn list_tickets( + &self, + status_filter: Option<&str>, + ) -> Result, AppError> { + self.repo + .list_tickets(status_filter) + .await + .map_err(AppError::Database) + } + + /// Для кнопки "Поддержка" в боте — показать контекст уже открытого + /// тикета юзера, если есть (последние сообщения треда). + pub async fn get_open_ticket_thread_for_user( + &self, + user_id: Uuid, + ) -> Result)>, AppError> { + let Some(ticket) = self + .repo + .get_open_ticket_for_user(user_id) + .await + .map_err(AppError::Database)? + else { + return Ok(None); + }; + let messages = self + .repo + .get_ticket_messages(ticket.id) + .await + .map_err(AppError::Database)?; + Ok(Some((ticket, messages))) + } + + pub async fn get_ticket_with_messages( + &self, + ticket_id: Uuid, + ) -> Result)>, AppError> { + let Some(ticket) = self + .repo + .get_ticket(ticket_id) + .await + .map_err(AppError::Database)? + else { + return Ok(None); + }; + let messages = self + .repo + .get_ticket_messages(ticket_id) + .await + .map_err(AppError::Database)?; + Ok(Some((ticket, messages))) + } + + /// Саппорт отвечает из админки — сохраняет сообщение и пытается + /// доставить его юзеру в Telegram. Ошибка отправки в Telegram (юзер + /// заблокировал бота, сетевой сбой у Telegram API и т.п.) не роняет + /// ответ целиком — сообщение уже сохранено в БД, саппорт увидит его в + /// треде даже если push не дошёл (та же resilience, что у расчёта + /// партнёрской комиссии в `BillingService::confirm_payment`). + pub async fn reply_as_staff( + &self, + ticket_id: Uuid, + staff_id: Uuid, + text: &str, + bot_token: &str, + user_tg_id: Option, + ) -> Result<(), AppError> { + self.repo + .add_support_message(ticket_id, "staff", Some(staff_id), text) + .await + .map_err(AppError::Database)?; + self.repo + .set_ticket_assigned(ticket_id, staff_id) + .await + .map_err(AppError::Database)?; + + match user_tg_id { + Some(tg_id) => { + let bot = Bot::new(bot_token); + let message = format!("💬 Ответ поддержки:\n\n{}", text); + if let Err(e) = bot + .send_message(ChatId(tg_id), message) + .parse_mode(teloxide::types::ParseMode::Html) + .await + { + tracing::warn!(error = ?e, ticket_id = %ticket_id, "Не удалось доставить ответ поддержки в Telegram — сообщение сохранено в БД"); + } + } + None => { + tracing::warn!(ticket_id = %ticket_id, "У юзера тикета нет tg_id — ответ сохранён, но доставить в Telegram некуда"); + } + } + Ok(()) + } + + pub async fn close_ticket(&self, ticket_id: Uuid) -> Result { + let ticket = self + .repo + .close_ticket(ticket_id) + .await + .map_err(AppError::Database)? + .ok_or_else(|| AppError::NotFound("Тикет не найден".into()))?; + metrics::counter!("support_tickets_total", "status" => "closed").increment(1); + Ok(ticket) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::repository::PgRepository; + use sqlx::PgPool; + + #[sqlx::test] + async fn test_handle_user_message_creates_ticket_and_resets_flag(pool: PgPool) { + let repo: Arc = Arc::new(PgRepository::new(pool)); + let svc = SupportService::new(repo.clone()); + let user = repo.upsert_user(111, None).await.unwrap(); + + svc.set_awaiting(user.id).await.unwrap(); + svc.handle_user_message(user.id, "не подключается на iOS") + .await + .unwrap(); + + let user_after = repo.get_user_by_id(user.id).await.unwrap().unwrap(); + assert!( + !user_after.awaiting_support_message, + "флаг должен сброситься сразу после получения сообщения" + ); + + let ticket = repo + .get_open_ticket_for_user(user.id) + .await + .unwrap() + .expect("должен появиться открытый тикет"); + let messages = repo.get_ticket_messages(ticket.id).await.unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].sender_role, "user"); + assert_eq!(messages[0].body, "не подключается на iOS"); + } + + #[sqlx::test] + async fn test_handle_user_message_reuses_open_ticket(pool: PgPool) { + let repo: Arc = Arc::new(PgRepository::new(pool)); + let svc = SupportService::new(repo.clone()); + let user = repo.upsert_user(222, None).await.unwrap(); + + svc.handle_user_message(user.id, "первое сообщение") + .await + .unwrap(); + let first_ticket = repo + .get_open_ticket_for_user(user.id) + .await + .unwrap() + .unwrap(); + + svc.handle_user_message(user.id, "второе сообщение, тот же тикет") + .await + .unwrap(); + let second_ticket = repo + .get_open_ticket_for_user(user.id) + .await + .unwrap() + .unwrap(); + + assert_eq!( + first_ticket.id, second_ticket.id, + "второе сообщение должно уйти в уже открытый тикет, а не создать новый" + ); + let messages = repo.get_ticket_messages(first_ticket.id).await.unwrap(); + assert_eq!(messages.len(), 2); + } + + #[sqlx::test] + async fn test_handle_user_message_creates_new_ticket_if_previous_closed(pool: PgPool) { + let repo: Arc = Arc::new(PgRepository::new(pool)); + let svc = SupportService::new(repo.clone()); + let user = repo.upsert_user(333, None).await.unwrap(); + + svc.handle_user_message(user.id, "первое обращение") + .await + .unwrap(); + let first_ticket = repo + .get_open_ticket_for_user(user.id) + .await + .unwrap() + .unwrap(); + repo.close_ticket(first_ticket.id).await.unwrap(); + + svc.handle_user_message(user.id, "новое обращение") + .await + .unwrap(); + let second_ticket = repo + .get_open_ticket_for_user(user.id) + .await + .unwrap() + .expect("должен появиться новый открытый тикет"); + + assert_ne!(first_ticket.id, second_ticket.id); + } +}