Роль Support + тикет-система техподдержки через бота
Единственный канал создания тикета — кнопка "Поддержка" в Telegram-боте: переводит юзера в разовый режим ожидания (awaiting_support_message), следующее свободное сообщение становится тикетом/ответом в уже открытый (случайный текст мимо кнопки по-прежнему игнорируется бота). Саппорты отвечают из веб-админки, ответ уходит юзеру обратно в тот же Telegram-чат. Новая роль support видит только раздел тикетов (support_access_guard) — создаётся только Owner'ом. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||||
+97
-5
@@ -8,7 +8,8 @@
|
|||||||
|
|
||||||
use crate::modules::{
|
use crate::modules::{
|
||||||
auth::service::AuthService, billing::service::BillingService,
|
auth::service::AuthService, billing::service::BillingService,
|
||||||
referrals::service::ReferralService, users::service::UserService,
|
referrals::service::ReferralService, support::service::SupportService,
|
||||||
|
users::service::UserService,
|
||||||
};
|
};
|
||||||
use teloxide::{
|
use teloxide::{
|
||||||
prelude::*,
|
prelude::*,
|
||||||
@@ -40,6 +41,7 @@ pub async fn run_bot(
|
|||||||
users: UserService,
|
users: UserService,
|
||||||
billing: BillingService,
|
billing: BillingService,
|
||||||
referrals: ReferralService,
|
referrals: ReferralService,
|
||||||
|
support: SupportService,
|
||||||
cancel_token: CancellationToken,
|
cancel_token: CancellationToken,
|
||||||
) {
|
) {
|
||||||
let config = BotConfig {
|
let config = BotConfig {
|
||||||
@@ -50,18 +52,27 @@ pub async fn run_bot(
|
|||||||
.expect("КРИТИЧЕСКАЯ ОШИБКА: WEB_APP_BASE_URL не задан!"),
|
.expect("КРИТИЧЕСКАЯ ОШИБКА: WEB_APP_BASE_URL не задан!"),
|
||||||
};
|
};
|
||||||
|
|
||||||
let handler = Update::filter_message().branch(
|
// Ветка команд (/start, /menu) — первая. Всё, что не распозналось как
|
||||||
|
// команда, падает во вторую ветку: там же живёт разовый захват
|
||||||
|
// свободного текста для тикетов поддержки (см. handle_free_text) —
|
||||||
|
// единственный канал создания тикета, кнопка "Поддержка" выставляет
|
||||||
|
// User::awaiting_support_message перед этим.
|
||||||
|
let handler = Update::filter_message()
|
||||||
|
.branch(
|
||||||
dptree::entry()
|
dptree::entry()
|
||||||
.filter_command::<Command>()
|
.filter_command::<Command>()
|
||||||
.endpoint(handle_commands),
|
.endpoint(handle_commands),
|
||||||
);
|
)
|
||||||
|
.branch(dptree::entry().endpoint(handle_free_text));
|
||||||
let callback_handler = Update::filter_callback_query().endpoint(handle_callbacks);
|
let callback_handler = Update::filter_callback_query().endpoint(handle_callbacks);
|
||||||
|
|
||||||
let mut dispatcher = Dispatcher::builder(
|
let mut dispatcher = Dispatcher::builder(
|
||||||
bot,
|
bot,
|
||||||
dptree::entry().branch(handler).branch(callback_handler),
|
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();
|
.build();
|
||||||
|
|
||||||
tokio::select! {
|
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()))]
|
#[instrument(skip(bot, auth), fields(chat_id = msg.chat.id.0, username = ?msg.chat.username()))]
|
||||||
async fn handle_commands(
|
async fn handle_commands(
|
||||||
bot: Bot,
|
bot: Bot,
|
||||||
@@ -139,7 +196,8 @@ async fn handle_commands(
|
|||||||
Ok(())
|
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(
|
async fn handle_callbacks(
|
||||||
bot: Bot,
|
bot: Bot,
|
||||||
q: CallbackQuery,
|
q: CallbackQuery,
|
||||||
@@ -147,6 +205,7 @@ async fn handle_callbacks(
|
|||||||
users: UserService,
|
users: UserService,
|
||||||
billing: BillingService,
|
billing: BillingService,
|
||||||
referrals: ReferralService,
|
referrals: ReferralService,
|
||||||
|
support: SupportService,
|
||||||
config: BotConfig,
|
config: BotConfig,
|
||||||
) -> ResponseResult<()> {
|
) -> ResponseResult<()> {
|
||||||
if let (Some(data), Some(msg)) = (q.data, q.message) {
|
if let (Some(data), Some(msg)) = (q.data, q.message) {
|
||||||
@@ -378,6 +437,35 @@ async fn handle_callbacks(
|
|||||||
("Ошибка связи с ядром системы.".into(), back_keyboard())
|
("Ошибка связи с ядром системы.".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 = "🆘 <b>ПОДДЕРЖКА</b>\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<b>Текущий тикет:</b>");
|
||||||
|
for m in messages.iter().rev().take(3).rev() {
|
||||||
|
let sender = if m.sender_role == "staff" { "Поддержка" } else { "Вы" };
|
||||||
|
text.push_str(&format!("\n\n<i>{}:</i> {}", 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 про то, почему это надёжнее
|
// AuthService::create_bot_login_code про то, почему это надёжнее
|
||||||
// deep-link-варианта (`?start=`).
|
// deep-link-варианта (`?start=`).
|
||||||
@@ -470,6 +558,10 @@ fn main_menu_keyboard() -> InlineKeyboardMarkup {
|
|||||||
"🔑 Код для входа в приложение",
|
"🔑 Код для входа в приложение",
|
||||||
"menu_app_login",
|
"menu_app_login",
|
||||||
)],
|
)],
|
||||||
|
vec![InlineKeyboardButton::callback(
|
||||||
|
"🆘 Поддержка",
|
||||||
|
"menu_support",
|
||||||
|
)],
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
fn profile_keyboard() -> InlineKeyboardMarkup {
|
fn profile_keyboard() -> InlineKeyboardMarkup {
|
||||||
|
|||||||
@@ -38,6 +38,45 @@ pub struct User {
|
|||||||
/// Партнёр, чей диплинк привёл этого пользователя (проставляется один
|
/// Партнёр, чей диплинк привёл этого пользователя (проставляется один
|
||||||
/// раз при регистрации, first-wins — см. `set_referred_by_partner`).
|
/// раз при регистрации, first-wins — см. `set_referred_by_partner`).
|
||||||
pub referred_by_partner_id: Option<Uuid>,
|
pub referred_by_partner_id: Option<Uuid>,
|
||||||
|
/// Разовый флаг "юзер нажал кнопку Поддержка в боте, следующее его
|
||||||
|
/// текстовое сообщение — тикет/ответ в тикет". Сбрасывается сразу после
|
||||||
|
/// получения текста (см. `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<Uuid>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, FromRow, Serialize)]
|
||||||
|
pub struct SupportMessage {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub ticket_id: Uuid,
|
||||||
|
pub sender_role: String,
|
||||||
|
pub sender_user_id: Option<Uuid>,
|
||||||
|
pub body: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Для списка тикетов в админке — один JOIN-запрос вместо N+1
|
||||||
|
/// (идентичность юзера + превью последнего сообщения).
|
||||||
|
#[derive(Debug, Clone, FromRow, Serialize)]
|
||||||
|
pub struct SupportTicketSummary {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub tg_id: Option<i64>,
|
||||||
|
pub tg_username: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
pub message_count: i64,
|
||||||
|
pub last_message_preview: Option<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, FromRow, Serialize)]
|
#[derive(Debug, Clone, FromRow, Serialize)]
|
||||||
|
|||||||
+184
-12
@@ -241,6 +241,36 @@ pub trait AppRepository: Send + Sync {
|
|||||||
processed_by: Uuid,
|
processed_by: Uuid,
|
||||||
) -> Result<Option<WithdrawalRequest>, sqlx::Error>;
|
) -> Result<Option<WithdrawalRequest>, 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<Option<SupportTicket>, sqlx::Error>;
|
||||||
|
async fn create_support_ticket(&self, user_id: Uuid) -> Result<SupportTicket, sqlx::Error>;
|
||||||
|
/// Добавляет сообщение и бампает `support_tickets.updated_at` в одной транзакции.
|
||||||
|
async fn add_support_message(
|
||||||
|
&self,
|
||||||
|
ticket_id: Uuid,
|
||||||
|
sender_role: &str,
|
||||||
|
sender_user_id: Option<Uuid>,
|
||||||
|
body: &str,
|
||||||
|
) -> Result<SupportMessage, sqlx::Error>;
|
||||||
|
/// `status_filter` — `None` = все тикеты (open первыми, затем по дате).
|
||||||
|
async fn list_tickets(
|
||||||
|
&self,
|
||||||
|
status_filter: Option<&str>,
|
||||||
|
) -> Result<Vec<SupportTicketSummary>, sqlx::Error>;
|
||||||
|
async fn get_ticket(&self, id: Uuid) -> Result<Option<SupportTicket>, sqlx::Error>;
|
||||||
|
async fn get_ticket_messages(
|
||||||
|
&self,
|
||||||
|
ticket_id: Uuid,
|
||||||
|
) -> Result<Vec<SupportMessage>, sqlx::Error>;
|
||||||
|
async fn close_ticket(&self, id: Uuid) -> Result<Option<SupportTicket>, sqlx::Error>;
|
||||||
|
/// Информационно: кто последним ответил — не enforced-назначение.
|
||||||
|
async fn set_ticket_assigned(&self, id: Uuid, staff_id: Uuid) -> Result<(), sqlx::Error>;
|
||||||
|
|
||||||
// --- VPN УЗЛЫ (CONTROL PLANE) ---
|
// --- VPN УЗЛЫ (CONTROL PLANE) ---
|
||||||
/// Ноды в статусе `online` — то, что видят обычные юзеры (`/nodes`, `/routing`, `/stats`).
|
/// Ноды в статусе `online` — то, что видят обычные юзеры (`/nodes`, `/routing`, `/stats`).
|
||||||
async fn get_active_nodes(&self) -> Result<Vec<VpnNode>, sqlx::Error>;
|
async fn get_active_nodes(&self) -> Result<Vec<VpnNode>, sqlx::Error>;
|
||||||
@@ -294,7 +324,7 @@ impl AppRepository for PgRepository {
|
|||||||
ON CONFLICT (tg_id) DO UPDATE SET tg_username = EXCLUDED.tg_username \
|
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, \
|
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, \
|
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
|
.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>(
|
sqlx::query_as::<_, User>(
|
||||||
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
|
"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, \
|
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"
|
FROM users WHERE id = $1"
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -317,7 +347,7 @@ impl AppRepository for PgRepository {
|
|||||||
sqlx::query_as::<_, User>(
|
sqlx::query_as::<_, User>(
|
||||||
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
|
"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, \
|
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"
|
FROM users WHERE tg_id = $1"
|
||||||
)
|
)
|
||||||
.bind(tg_id)
|
.bind(tg_id)
|
||||||
@@ -331,7 +361,7 @@ impl AppRepository for PgRepository {
|
|||||||
"INSERT INTO users (id, seed_hash) VALUES ($1, $2) \
|
"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, \
|
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, \
|
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
|
.bind(Uuid::new_v4()).bind(seed_hash).fetch_one(&self.pool).await
|
||||||
}
|
}
|
||||||
@@ -341,7 +371,7 @@ impl AppRepository for PgRepository {
|
|||||||
sqlx::query_as::<_, User>(
|
sqlx::query_as::<_, User>(
|
||||||
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
|
"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, \
|
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"
|
FROM users WHERE seed_hash = $1"
|
||||||
)
|
)
|
||||||
.bind(hash)
|
.bind(hash)
|
||||||
@@ -376,7 +406,7 @@ impl AppRepository for PgRepository {
|
|||||||
"UPDATE users SET data_limit_bytes = $1 WHERE id = $2 \
|
"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, \
|
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, \
|
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(limit_bytes)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
@@ -965,7 +995,7 @@ impl AppRepository for PgRepository {
|
|||||||
VALUES ($1, $2, $3, $4, $5, $6, $7) \
|
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, \
|
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, \
|
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(Uuid::new_v4())
|
||||||
.bind(username)
|
.bind(username)
|
||||||
@@ -983,7 +1013,7 @@ impl AppRepository for PgRepository {
|
|||||||
sqlx::query_as::<_, User>(
|
sqlx::query_as::<_, User>(
|
||||||
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
|
"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, \
|
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"
|
FROM users WHERE username = $1"
|
||||||
)
|
)
|
||||||
.bind(username)
|
.bind(username)
|
||||||
@@ -1001,7 +1031,7 @@ impl AppRepository for PgRepository {
|
|||||||
"UPDATE users SET can_manage_nodes = $1 WHERE id = $2 \
|
"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, \
|
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, \
|
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(enabled)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
@@ -1013,12 +1043,12 @@ impl AppRepository for PgRepository {
|
|||||||
async fn list_staff_users(&self, role_filter: Option<&str>) -> Result<Vec<User>, sqlx::Error> {
|
async fn list_staff_users(&self, role_filter: Option<&str>) -> Result<Vec<User>, sqlx::Error> {
|
||||||
let roles: Vec<&str> = match role_filter {
|
let roles: Vec<&str> = match role_filter {
|
||||||
Some(r) => vec![r],
|
Some(r) => vec![r],
|
||||||
None => vec!["owner", "moderator", "partner"],
|
None => vec!["owner", "moderator", "partner", "support"],
|
||||||
};
|
};
|
||||||
sqlx::query_as::<_, User>(
|
sqlx::query_as::<_, User>(
|
||||||
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
|
"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, \
|
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"
|
FROM users WHERE role = ANY($1) ORDER BY role, username"
|
||||||
)
|
)
|
||||||
.bind(&roles)
|
.bind(&roles)
|
||||||
@@ -1031,7 +1061,7 @@ impl AppRepository for PgRepository {
|
|||||||
sqlx::query_as::<_, User>(
|
sqlx::query_as::<_, User>(
|
||||||
"SELECT id, tg_id, tg_username, role, balance, game_tickets, has_used_trial, seed_hash, last_hack_at, \
|
"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, \
|
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'"
|
FROM users WHERE partner_code = $1 AND role = 'partner'"
|
||||||
)
|
)
|
||||||
.bind(code)
|
.bind(code)
|
||||||
@@ -1303,6 +1333,148 @@ impl AppRepository for PgRepository {
|
|||||||
.await
|
.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<Option<SupportTicket>, 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<SupportTicket, sqlx::Error> {
|
||||||
|
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<Uuid>,
|
||||||
|
body: &str,
|
||||||
|
) -> Result<SupportMessage, sqlx::Error> {
|
||||||
|
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<Vec<SupportTicketSummary>, 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<Option<SupportTicket>, 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<Vec<SupportMessage>, 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<Option<SupportTicket>, 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)
|
// VPN УЗЛЫ (CONTROL PLANE)
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|||||||
+5
-1
@@ -18,7 +18,8 @@ use netrunner_control_plane::bot;
|
|||||||
use netrunner_control_plane::db::repository::{AppRepository, PgRepository};
|
use netrunner_control_plane::db::repository::{AppRepository, PgRepository};
|
||||||
use netrunner_control_plane::modules::{
|
use netrunner_control_plane::modules::{
|
||||||
auth::service::AuthService, billing::service::BillingService, nodes::service::NodeService,
|
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;
|
use netrunner_control_plane::tasks;
|
||||||
|
|
||||||
@@ -296,6 +297,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
referral_service: ReferralService::new(repo.clone()),
|
referral_service: ReferralService::new(repo.clone()),
|
||||||
user_service: UserService::new(repo.clone()),
|
user_service: UserService::new(repo.clone()),
|
||||||
staff_service: StaffService::new(repo.clone()),
|
staff_service: StaffService::new(repo.clone()),
|
||||||
|
support_service: SupportService::new(repo.clone()),
|
||||||
jwt_secret,
|
jwt_secret,
|
||||||
bot_token: bot_token.clone(),
|
bot_token: bot_token.clone(),
|
||||||
bot_username,
|
bot_username,
|
||||||
@@ -323,6 +325,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let bot_users = state.user_service.clone();
|
let bot_users = state.user_service.clone();
|
||||||
let bot_billing = state.billing_service.clone();
|
let bot_billing = state.billing_service.clone();
|
||||||
let bot_referrals = state.referral_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_token_for_task = bot_token.clone();
|
||||||
|
|
||||||
let bot_handle = tokio::spawn(async move {
|
let bot_handle = tokio::spawn(async move {
|
||||||
@@ -334,6 +337,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
bot_users,
|
bot_users,
|
||||||
bot_billing,
|
bot_billing,
|
||||||
bot_referrals,
|
bot_referrals,
|
||||||
|
bot_support,
|
||||||
bot_cancel_token,
|
bot_cancel_token,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
@@ -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<ApiState>,
|
||||||
|
req: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
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.
|
/// Только Partner.
|
||||||
pub async fn partner_guard(
|
pub async fn partner_guard(
|
||||||
State(_state): State<ApiState>,
|
State(_state): State<ApiState>,
|
||||||
@@ -205,6 +220,7 @@ mod tests {
|
|||||||
referral_service: ReferralService::new(repo.clone()),
|
referral_service: ReferralService::new(repo.clone()),
|
||||||
user_service: UserService::new(repo.clone()),
|
user_service: UserService::new(repo.clone()),
|
||||||
staff_service: StaffService::new(repo.clone()),
|
staff_service: StaffService::new(repo.clone()),
|
||||||
|
support_service: crate::modules::support::service::SupportService::new(repo.clone()),
|
||||||
jwt_secret: "test-secret".into(),
|
jwt_secret: "test-secret".into(),
|
||||||
bot_token: "test-bot-token".into(),
|
bot_token: "test-bot-token".into(),
|
||||||
bot_username: "test_bot".into(),
|
bot_username: "test_bot".into(),
|
||||||
@@ -235,6 +251,7 @@ mod tests {
|
|||||||
commission_percent: None,
|
commission_percent: None,
|
||||||
partner_code: None,
|
partner_code: None,
|
||||||
referred_by_partner_id: None,
|
referred_by_partner_id: None,
|
||||||
|
awaiting_support_message: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,4 +377,54 @@ mod tests {
|
|||||||
StatusCode::UNAUTHORIZED
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
//! Авторизация: Telegram Login Widget, анонимный "Ghost Protocol" (вход по seed-фразе),
|
//! Авторизация: Telegram Login Widget, анонимный "Ghost Protocol" (вход по seed-фразе),
|
||||||
//! логин Owner/Moderator/Partner по паролю, и ролевые JWT-гварды (`auth_guard` +
|
//! логин Owner/Moderator/Partner/Support по паролю, и ролевые JWT-гварды (`auth_guard` +
|
||||||
//! `owner_guard`/`staff_guard`/`node_manage_guard`/`partner_guard`). См. README.md рядом.
|
//! `owner_guard`/`staff_guard`/`node_manage_guard`/`partner_guard`/`support_access_guard`).
|
||||||
|
//! См. README.md рядом.
|
||||||
|
|
||||||
pub mod controller;
|
pub mod controller;
|
||||||
pub mod guard;
|
pub mod guard;
|
||||||
pub mod service;
|
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};
|
pub use service::{AuthService, Claims};
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ pub mod nodes;
|
|||||||
pub mod proxy_internal;
|
pub mod proxy_internal;
|
||||||
pub mod referrals;
|
pub mod referrals;
|
||||||
pub mod staff;
|
pub mod staff;
|
||||||
|
pub mod support;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ impl StaffService {
|
|||||||
.map_err(|e| map_create_error(e, "модератора"))
|
.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<User, AppError> {
|
||||||
|
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(
|
pub async fn create_partner(
|
||||||
&self,
|
&self,
|
||||||
username: &str,
|
username: &str,
|
||||||
|
|||||||
@@ -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<ApiState> {
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_tickets(
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
Query(q): Query<ListTicketsQuery>,
|
||||||
|
) -> Result<Json<Value>, 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<ApiState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Value>, 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<User>,
|
||||||
|
State(state): State<ApiState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(p): Json<ReplyPayload>,
|
||||||
|
) -> Result<Json<Value>, 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<ApiState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
let ticket = state.support_service.close_ticket(id).await?;
|
||||||
|
Ok(Json(json!(ticket)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
//! Тикеты техподдержки — единственный канал создания: кнопка "Поддержка" в
|
||||||
|
//! Telegram-боте (см. `crate::bot`). Ответ саппорта — из веб-админки,
|
||||||
|
//! доставляется юзеру обратно в тот же Telegram-чат. См. `service.rs`.
|
||||||
|
|
||||||
|
pub mod controller;
|
||||||
|
pub mod service;
|
||||||
|
|
||||||
|
pub use service::SupportService;
|
||||||
@@ -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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user