Ролевая админка (Owner/Moderator/Partner) + оплата через @CryptoBot

Ролевая система вместо привязки к Telegram
----------------------------------------
Раньше единственный способ получить админ-доступ — admin_guard, пускавший
по ADMIN_TG_ID или role=="admin" (без сеттера для роли). Заменяет на
Owner/Moderator/Partner:

- migrations/0005_roles_and_partners.sql — username/password_hash на users
  (NULL у обычных Ghost/Telegram-юзеров, тот же паттерн, что и у seed_hash/
  tg_id), can_manage_nodes, commission_percent, partner_code,
  referred_by_partner_id; новые таблицы partner_commissions и
  withdrawal_requests.
- auth/guard.rs — admin_guard заменён на owner_guard/staff_guard/
  node_manage_guard/partner_guard; ADMIN_TG_ID убран из ApiState и main.rs.
- auth/service.rs — hash_password/login_staff со случайной Argon2-солью на
  каждого юзера (не общий фиксированный ARGON_SALT, который годится для
  высокоэнтропийных seed-логинов, но не для человеческих паролей).
- POST /api/v1/auth/admin/login — вход модератора/партнёра/овнера паролем,
  переиспользует существующий issue_session().
- --create-owner CLI-флаг (main.rs) со скрытым вводом пароля (rpassword) —
  вместо ручного SQL для создания первого владельца с VPS-консоли.
- src/modules/staff/ — эндпоинты создания модераторов/партнёров, переключения
  can_manage_nodes, списка заявок на вывод и их approve/reject/paid.
- Комиссия партнёра начисляется в BillingService::confirm_payment отдельным
  шагом после существующей рефералки, пересчитывается через
  get_plan_price(invoice.plan_name, invoice.currency) — НЕ через
  invoice.amount напрямую, потому что у провайдеров разная внутренняя
  единица (TON — nanoTON+2% буфер, CryptoBot — фиатные центы напрямую).
- bot.rs — /start p_XXXXXX привязывает покупателя к партнёру по коду
  (first-wins, до существующих веток numeric-tg_id-рефералки и кода входа).
- frontend/ — AdminLogin/AdminLayout/StaffPage/WithdrawalsPage/EarningsPage
  поверх существующего Vite-админ-фронта (без отдельного проекта).

Оплата через @CryptoBot
------------------------
- modules/billing/providers/cryptobot.rs — новый PaymentProvider (Crypto Pay
  API), плюс попутно исправлены два реальных бага в существующем коде:
  confirm_payment был жёстко завязан на TON-провайдера при любом инвойсе, и
  InvoiceRecord не хранил currency, из-за чего TON-курс молча применялся ко
  всем провайдерам вместо родной валюты инвойса.
- bot.rs — экран выбора способа оплаты для CryptoBot-инвойсов.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 22:53:02 +07:00
parent c8eea8203e
commit 375b333978
31 changed files with 2607 additions and 115 deletions
+144 -2
View File
@@ -71,8 +71,12 @@ export async function apiFetch(
const refreshed = await refreshSession();
if (!refreshed) {
if (window.location.pathname !== "/") {
window.location.href = "/";
// Админка (Owner/Moderator/Partner) логинится отдельно от обычных
// Ghost/Telegram-юзеров — редирект на "/" был бы неверным экраном для неё.
const isAdminPath = window.location.pathname.startsWith("/admin");
const loginPath = isAdminPath ? "/admin/login" : "/";
if (window.location.pathname !== loginPath) {
window.location.href = loginPath;
}
return res;
}
@@ -161,6 +165,144 @@ export const exchangeBootstrapToken = async (token: string): Promise<boolean> =>
return res.ok;
};
// --- Ролевая админка (Owner/Moderator/Partner) ---
export const adminLogin = async (username: string, password: string) => {
const res = await fetch(`${API_BASE}/auth/admin/login`, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
return parseOrThrow(res, "Login failed");
};
export interface StaffRole {
role: "owner" | "moderator" | "partner" | "user" | "admin";
can_manage_nodes: boolean;
username: string | null;
partner_code: string | null;
commission_percent: string | null;
}
export const fetchMyRole = async (): Promise<StaffRole> => {
const res = await apiFetch("/admin/staff/me");
return parseOrThrow(res, "Failed to fetch role");
};
export const createModerator = async (
username: string,
password: string,
canManageNodes: boolean,
) => {
const res = await apiFetch("/admin/staff/moderators", {
method: "POST",
body: JSON.stringify({
username,
password,
can_manage_nodes: canManageNodes,
}),
});
return parseOrThrow(res, "Failed to create moderator");
};
export const createPartner = async (
username: string,
password: string,
commissionPercent: number,
) => {
const res = await apiFetch("/admin/staff/partners", {
method: "POST",
body: JSON.stringify({
username,
password,
commission_percent: commissionPercent,
}),
});
return parseOrThrow(res, "Failed to create partner");
};
export interface StaffUser {
id: string;
username: string | null;
role: string;
can_manage_nodes: boolean;
commission_percent: string | null;
partner_code: string | null;
}
export const fetchStaffUsers = async (): Promise<StaffUser[]> => {
const res = await apiFetch("/admin/staff/staff-users");
return parseOrThrow(res, "Failed to fetch staff users");
};
export const setNodePermission = async (userId: string, canManageNodes: boolean) => {
const res = await apiFetch(`/admin/staff/users/${userId}/permissions`, {
method: "PATCH",
body: JSON.stringify({ can_manage_nodes: canManageNodes }),
});
return parseOrThrow(res, "Failed to update permissions");
};
export interface WithdrawalRequest {
id: string;
partner_id: string;
currency: string;
amount: number;
status: "pending" | "approved" | "rejected" | "paid";
note: string | null;
created_at: string;
processed_at: string | null;
processed_by: string | null;
}
export const fetchWithdrawals = async (): Promise<WithdrawalRequest[]> => {
const res = await apiFetch("/admin/staff/withdrawals");
return parseOrThrow(res, "Failed to fetch withdrawals");
};
export const updateWithdrawal = async (
id: string,
status: "approved" | "rejected" | "paid",
) => {
const res = await apiFetch(`/admin/staff/withdrawals/${id}`, {
method: "PATCH",
body: JSON.stringify({ status }),
});
return parseOrThrow(res, "Failed to update withdrawal");
};
export interface PartnerEarnings {
currency: string;
total_earned: number;
total_withdrawn: number;
pending_withdrawal: number;
available: number;
}
export interface MyEarnings {
earnings: PartnerEarnings[];
withdrawals: WithdrawalRequest[];
partner_code: string | null;
}
export const fetchMyEarnings = async (): Promise<MyEarnings> => {
const res = await apiFetch("/admin/partners/earnings");
return parseOrThrow(res, "Failed to fetch earnings");
};
export const requestWithdrawal = async (
currency: string,
amount: number,
note?: string,
) => {
const res = await apiFetch("/admin/partners/withdrawals", {
method: "POST",
body: JSON.stringify({ currency, amount, note }),
});
return parseOrThrow(res, "Failed to submit withdrawal request");
};
export const logout = async () => {
await fetch(`${API_BASE}/auth/logout`, {
method: "POST",