d543fb5585
Единственный канал создания тикета — кнопка "Поддержка" в Telegram-боте: переводит юзера в разовый режим ожидания (awaiting_support_message), следующее свободное сообщение становится тикетом/ответом в уже открытый (случайный текст мимо кнопки по-прежнему игнорируется бота). Саппорты отвечают из веб-админки, ответ уходит юзеру обратно в тот же Telegram-чат. Новая роль support видит только раздел тикетов (support_access_guard) — создаётся только Owner'ом. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
269 lines
10 KiB
Rust
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);
|
|
}
|
|
}
|