feat: pause/resume a subscription without losing paid time (Remnawave-style hold)
From the original night's low-priority backlog item ("user on hold
status") — the only lever admin had for cutting a customer's access was
Revoke, which is permanent: the subscription's remaining days are just
gone, and restoring access means manually granting a brand-new one and
eyeballing how many days to give back. No way to say "block this for a
few days, then give the exact remaining time back."
db.py: new held_at column on subscriptions (same ALTER-TABLE migration
pattern as every other column added this week). hold_subscription()
sets it, guarded to only fire on a subscription that's currently active,
not already held, not expired — returns False instead of silently
no-opping so the caller can tell holding didn't happen. resume_subscription()
shifts expires_at forward by exactly how long it was held (now - held_at)
and clears held_at, so a subscription paused for 3 days comes back with
3 days added, not 3 days lost.
The part that actually mattered for correctness: list_active_subscriptions()
now also requires held_at IS NULL. This function is what xray_manager's
periodic sync (every 90s) uses to decide which clients belong in Xray's
config — without this exclusion, holding a subscription would look like
it worked for about 90 seconds and then the next sync would silently
re-add the client, since the row still has active=1 and a future
expires_at. Found this by actually tracing sync_from_db()/sync_all()
before writing the hold logic, not after debugging a live failure.
api.py: POST .../hold and .../resume routes, mirroring the existing
revoke route (fetch the sub, touch the node's xray client immediately
rather than waiting for the next periodic sync, same as revoke already
does). _days_left() now takes the whole subscription row instead of just
expires_at, so it can use held_at as the reference point instead of "now"
for a held subscription — otherwise the admin UI would show the days
counter silently ticking down while the customer isn't even able to use
the service.
admin.html: Пауза/Возобновить buttons next to Отозвать in both the main
Подписки table and the per-user card, a "на паузе" badge, and a doc-block
explaining the hold-vs-revoke distinction. Also fixed a latent race while
touching this code: the old inline revoke handler in the user card fired
openUserCard() immediately alongside revokeSub() without waiting for it,
so the card could refresh before the revoke's own API call had finished;
switched to .then() so hold/resume/revoke all correctly wait for the
action before refreshing the card.
Verification: db.py has no fastapi/aiogram dependency so this was fully
testable locally, unlike most of tonight's api.py/bot.py-touching work.
16 checks against a real isolated sqlite db: hold/resume round-trip,
the exclude-from-active-list behavior the xray sync depends on, the
exact hours-shift math (simulated a 5h hold by rewriting held_at
directly, verified the resumed expires_at landed within 6 minutes of
the expected shift), and edge cases — double-hold, double-resume,
holding an expired or already-revoked subscription, nonexistent uuid.
AST-extracted the updated _days_left() out of api.py (still can't
import the module directly) and ran it against hand-built held/active
subscription dicts. Added the same hold/resume sequence to the existing
CI "TOTP/backup/reorder" step and ran that step's exact full script
locally end to end before committing — all six of its sections pass
together, not just the new one in isolation.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
7cc50973f6
commit
906b1f5842
4 changed files with 118 additions and 12 deletions
35
admin.html
35
admin.html
|
|
@ -620,6 +620,11 @@
|
|||
<p>Настройки → «Лимит устройств» — глобальный рубильник и лимит по умолчанию (как у Remnawave: клиент шлёт заголовок <code>x-hwid</code> при запросе конфига, панель запоминает первые N уникальных устройств на юзера и отказывает новым сверх лимита). У конкретного юзера лимит можно переопределить отдельно — в его карточке (Подписки → кнопка «Карточка» → таб «Устройства»), это имеет приоритет над глобальным значением по умолчанию.</p>
|
||||
</div>
|
||||
|
||||
<div class="doc-block">
|
||||
<h2>Пауза подписки</h2>
|
||||
<p>Кнопка «Пауза» у активной подписки (в Подписках и в карточке юзера) — не то же самое, что «Отозвать». Пауза сразу убирает клиента из Xray (доступ пропадает), но остаток срока сохраняется: сколько дней было на паузе — ровно столько добавится к <code>expires_at</code> при нажатии «Возобновить». «Отозвать», наоборот, необратимо — новую подписку тогда выдаёт только «Карточка» → ручная выдача.</p>
|
||||
</div>
|
||||
|
||||
<div class="doc-block">
|
||||
<h2>fail2ban</h2>
|
||||
<p><code>install.sh</code> ставит и включает fail2ban автоматически (дефолтный jail — защита SSH от перебора паролей). Проверить, что работает:</p>
|
||||
|
|
@ -1383,7 +1388,8 @@ async function restoreBackup() {
|
|||
}
|
||||
|
||||
function fmtDate(iso) { return iso ? iso.slice(0, 10) : "—"; }
|
||||
function statusBadge(active, daysLeft) {
|
||||
function statusBadge(active, daysLeft, held) {
|
||||
if (held) return '<span class="badge warn">на паузе</span>';
|
||||
if (!active) return '<span class="badge bad">истекла</span>';
|
||||
if (daysLeft <= 2) return '<span class="badge warn">' + daysLeft + ' дн.</span>';
|
||||
return '<span class="badge ok">' + daysLeft + ' дн.</span>';
|
||||
|
|
@ -1402,7 +1408,7 @@ async function loadDashboard() {
|
|||
const body = document.getElementById("recent-subs-body");
|
||||
body.innerHTML = recent.length ? recent.map((s, i) => `
|
||||
<tr ${rowAttr(i)}><td>${s.username ? "@" + esc(s.username) : "tg" + s.tg_id}</td><td>${esc(s.node_label)}</td><td>${esc(s.plan_label)}</td>
|
||||
<td>${fmtDate(s.expires_at)}</td><td>${statusBadge(s.active, s.days_left)}</td></tr>
|
||||
<td>${fmtDate(s.expires_at)}</td><td>${statusBadge(s.active, s.days_left, s.held_at)}</td></tr>
|
||||
`).join("") : '<tr><td colspan="5"><div class="empty">Пока нет подписок</div></td></tr>';
|
||||
}
|
||||
|
||||
|
|
@ -1411,9 +1417,12 @@ async function loadSubscriptions() {
|
|||
const body = document.getElementById("subs-body");
|
||||
body.innerHTML = subs.length ? subs.map((s, i) => `
|
||||
<tr ${rowAttr(i)}><td>${s.username ? "@" + esc(s.username) : "tg" + s.tg_id}</td><td>${esc(s.node_label)}</td><td>${esc(s.plan_label)}</td>
|
||||
<td>${fmtDate(s.created_at)}</td><td>${fmtDate(s.expires_at)}</td><td>${statusBadge(s.active, s.days_left)}</td>
|
||||
<td>${fmtDate(s.created_at)}</td><td>${fmtDate(s.expires_at)}</td><td>${statusBadge(s.active, s.days_left, s.held_at)}</td>
|
||||
<td>
|
||||
<button class="muted-btn" onclick="openUserCard(${s.tg_id}, '${esc(s.username ? '@' + s.username : 'tg' + s.tg_id)}')">Карточка</button>
|
||||
${s.active ? (s.held_at
|
||||
? `<button class="muted-btn" onclick="resumeSub('${s.uuid}')">Возобновить</button>`
|
||||
: `<button class="muted-btn" onclick="holdSub('${s.uuid}')">Пауза</button>`) : ""}
|
||||
${s.active ? `<button class="muted-btn" onclick="revokeSub('${s.uuid}')">Отозвать</button>` : ""}
|
||||
</td></tr>
|
||||
`).join("") : '<tr><td colspan="7"><div class="empty">Пока нет подписок</div></td></tr>';
|
||||
|
|
@ -1445,15 +1454,21 @@ function switchUserTab(tab) {
|
|||
document.getElementById("uc-tab-subs").style.display = tab === "subs" ? "block" : "none";
|
||||
document.getElementById("uc-tab-devices").style.display = tab === "devices" ? "block" : "none";
|
||||
}
|
||||
function refreshUserCard() {
|
||||
openUserCard(devicesTgId, document.getElementById("devices-title").textContent.replace("Карточка: ", ""));
|
||||
}
|
||||
function renderUcSubs(subs) {
|
||||
const list = document.getElementById("uc-subs-list");
|
||||
list.innerHTML = subs.length ? subs.map((s, i) => `
|
||||
<div class="uc-sub-row reveal" style="animation-delay:${Math.min(i, 10) * 0.025}s">
|
||||
<div>
|
||||
<div>${esc(s.node_label)} — ${esc(s.plan_label)}</div>
|
||||
<div class="page-sub" style="margin:2px 0 0">до ${fmtDate(s.expires_at)} · ${statusBadge(s.active, s.days_left)}</div>
|
||||
<div class="page-sub" style="margin:2px 0 0">до ${fmtDate(s.expires_at)} · ${statusBadge(s.active, s.days_left, s.held_at)}</div>
|
||||
</div>
|
||||
${s.active ? `<button class="muted-btn" onclick="revokeSub('${s.uuid}'); openUserCard(devicesTgId, document.getElementById('devices-title').textContent.replace('Карточка: ',''))">Отозвать</button>` : ""}
|
||||
${s.active ? (s.held_at
|
||||
? `<button class="muted-btn" onclick="resumeSub('${s.uuid}').then(refreshUserCard)">Возобновить</button>`
|
||||
: `<button class="muted-btn" onclick="holdSub('${s.uuid}').then(refreshUserCard)">Пауза</button>`) : ""}
|
||||
${s.active ? `<button class="muted-btn" onclick="revokeSub('${s.uuid}').then(refreshUserCard)">Отозвать</button>` : ""}
|
||||
</div>
|
||||
`).join("") : '<div class="empty">Пока нет подписок</div>';
|
||||
}
|
||||
|
|
@ -1492,6 +1507,16 @@ async function revokeSub(uuid) {
|
|||
loadSubscriptions();
|
||||
}
|
||||
|
||||
async function holdSub(uuid) {
|
||||
await api(`/admin/api/subscriptions/${uuid}/hold`, { method: "POST" });
|
||||
loadSubscriptions();
|
||||
}
|
||||
|
||||
async function resumeSub(uuid) {
|
||||
await api(`/admin/api/subscriptions/${uuid}/resume`, { method: "POST" });
|
||||
loadSubscriptions();
|
||||
}
|
||||
|
||||
let plansCache = null, nodesCache = null;
|
||||
|
||||
async function loadGifts() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue