Files
netrunner-backend/src/modules/support/service.rs
T
nineap d543fb5585 Роль Support + тикет-система техподдержки через бота
Единственный канал создания тикета — кнопка "Поддержка" в Telegram-боте:
переводит юзера в разовый режим ожидания (awaiting_support_message),
следующее свободное сообщение становится тикетом/ответом в уже открытый
(случайный текст мимо кнопки по-прежнему игнорируется бота). Саппорты
отвечают из веб-админки, ответ уходит юзеру обратно в тот же
Telegram-чат. Новая роль support видит только раздел тикетов
(support_access_guard) — создаётся только Owner'ом.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 23:20:09 +07:00

269 lines
10 KiB
Rust

//! Бизнес-логика тикетов техподдержки. Единственный канал создания
//! тикета/сообщения — бот (`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<dyn AppRepository>,
}
impl SupportService {
pub fn new(repo: Arc<dyn AppRepository>) -> 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<Vec<SupportTicketSummary>, 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<Option<(SupportTicket, Vec<SupportMessage>)>, 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<Option<(SupportTicket, Vec<SupportMessage>)>, 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<i64>,
) -> 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!("💬 <b>Ответ поддержки:</b>\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<SupportTicket, AppError> {
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<dyn AppRepository> = 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<dyn AppRepository> = 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<dyn AppRepository> = 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);
}
}