Управление пользователями
Build and push Docker image / docker (push) Successful in 2m11s Details

This commit is contained in:
MpakobecEx 2026-10-18 21:02:44 +05:00
parent a9df213714
commit 99cea7cb8f
10 changed files with 355 additions and 8 deletions

View File

@ -13,6 +13,7 @@ Telegram-бот для генерации и редактирования изо
- **Оплата**: 5 Telegram Stars за обычную очередь, 15 за премиум; админам бесплатно, возврат при ошибке.
- **Премиум-очередь**: чередуется с обычной (одна премиум, одна обычная); если одна пуста — работает другая.
- **Баланс и рефералы**: 3 стартовые генерации, +1 ежедневно в 00:00 МСК, +3 за первую оплату друга.
- **Управление пользователями**: в веб-интерфейсе можно выдавать админку, менять баланс и блокировать.
- Глобальная очередь с одной активной задачей на пользователя.
- Позиция в очереди и прогресс генерации через WebSocket ComfyUI.
- SQLite: запросы, статусы, seed, пути к файлам.
@ -124,6 +125,19 @@ BLOCKED_PUBLIC_FIGURES=
При блокировке пользователь получает «Запрос нарушает правила. Звёзды не списаны.», а запись с `user_id`, временем, промптом и причиной сохраняется в таблицу `blocked_requests`.
## Управление пользователями
В веб-интерфейсе есть раздел **«Пользователи»** (`/users`):
- поиск по ID или username;
- установка точного баланса генераций;
- назначение/снятие админа;
- блокировка/разблокировка.
Заблокированный пользователь не может отправлять запросы, получать счета и оплачивать. Админ-флаг из веб-интерфейса действует сразу.
`ADMIN_USER_IDS` используется только для первичной инициализации админов; после первого запуска админы хранятся в БД и управляются через веб.
## Баланс и рефералы
- Новому пользователю начисляется 3 генерации.
@ -149,7 +163,7 @@ PREMIUM_STARS_PRICE=15
- Перед оплатой бот показывает количество людей в обычной и премиум-очередях.
- Пользователь выбирает очередь кнопкой, после чего получает счёт.
- Премиум- и обычные задачи чередуются: одна премиум, одна обычная; если одна очередь пуста, берётся из другой.
- Админы из `ADMIN_USER_IDS` генерируют бесплатно и попадают в премиум-очередь.
- Админы из `ADMIN_USER_IDS` генерируют бесплатно в обычной очереди.
- Не-админу выставляется счёт через Telegram Stars (`XTR`).
- Фильтрация промпта выполняется до выставления счёта.
- Если поставить задачу в очередь не удалось, звёзды возвращаются.

28
bot.py
View File

@ -113,6 +113,7 @@ class MpakoBot:
self._settings.comfy_ws_url,
)
await self._db.connect()
await self._db.seed_admins(self._settings.admin_user_ids)
await self._db.accrue_daily_credits()
self._daily_task = asyncio.create_task(
self._daily_credits_loop(), name="daily-credits"
@ -324,12 +325,14 @@ class MpakoBot:
if query is None:
return
pending = await self._db.get_pending_payment(query.invoice_payload)
user_row = await self._db.get_user(query.from_user.id)
allowed = (
pending is not None
and pending.user_id == query.from_user.id
and pending.status == "pending"
and query.currency == "XTR"
and query.total_amount == pending.amount
and not (user_row is not None and user_row.is_blocked)
)
try:
await query.answer(
@ -373,6 +376,11 @@ class MpakoBot:
)
return
user_row = await self._db.get_user(query.from_user.id)
if user_row is not None and user_row.is_blocked:
await query.answer("Доступ заблокирован.", show_alert=True)
return
if tier == "credit":
remaining = await self._db.consume_credit(query.from_user.id)
if remaining is None:
@ -465,6 +473,17 @@ class MpakoBot:
if pending.status != "pending":
return
user_row = await self._db.get_user(user.id)
if user_row is not None and user_row.is_blocked:
refunded = await self._refund_stars(
user.id, payment.telegram_payment_charge_id
)
if refunded:
await self._db.mark_pending_refunded(payload)
note = " Звёзды возвращены." if refunded else ""
await message.reply_text("Доступ заблокирован." + note)
return
charge_id = payment.telegram_payment_charge_id
if payment.currency != "XTR" or payment.total_amount != pending.amount:
refunded = await self._refund_stars(user.id, charge_id)
@ -641,8 +660,11 @@ class MpakoBot:
if message is None or user is None or self._queue is None:
return False
is_admin = user.id in self._settings.admin_user_ids
user_row = await self._db.ensure_user(user.id, user.username)
is_admin = user_row.is_admin
if user_row.is_blocked:
await message.reply_text("Доступ заблокирован.")
return False
credits = user_row.credits
has_person = False
if (
@ -1067,6 +1089,10 @@ class MpakoBot:
):
await message.reply_text("Доступ к боту ограничен.")
return False
row = await self._db.get_user(user.id)
if row is not None and row.is_blocked:
await message.reply_text("Доступ заблокирован.")
return False
return True
async def _on_error(

View File

@ -6,9 +6,9 @@
## Текущий статус
- Реализованы блоки 2–7, базовое хранилище (9), файлы Docker (10), тесты (11), README (12), веб-история (13), улучшение промптов (14), модерация (15), оплата Stars (16) и баланс/рефералы (17).
- Реализованы блоки 2–7, базовое хранилище (9), файлы Docker (10), тесты (11), README (12), веб-история (13), улучшение промптов (14), модерация (15), оплата Stars (16), баланс/рефералы (17) и управление пользователями (18).
- Вживую проверены: T2I (Z-Image), I2I (Krea2), WebSocket-прогресс, улучшение промптов, Docker, CI и веб-история.
- 50 тестов проходят: БД, workflows, очередь, веб-история, enhancer, модерация, платежи, премиум-очередь, баланс/рефералы, вспомогательные функции ComfyUI.
- 52 теста проходят: БД, workflows, очередь, веб-история, enhancer, модерация, платежи, премиум-очередь, баланс/рефералы, управление пользователями.
- Не проверено: реальная оплата Stars и реферальная выплата в Telegram, очередь из 3+ пользователей, падение ComfyUI, рестарт с `running`-задачей, детекция лица на реальном фото.
## 0. Допущения и решения по умолчанию
@ -351,6 +351,16 @@ ON requests(user_id) WHERE status IN ('queued', 'running');
**DoD:** новый пользователь получает 3 генерации, ежедневно +1, друг после первой оплаты приносит рефереру +3.
## 18. Управление пользователями в веб
- [x] Страница `/users`: поиск, баланс, админ-флаг, блокировка.
- [x] POST-действия: изменить баланс, назначить/снять админа, заблокировать.
- [x] Блокировка проверяется ботом при запросах, оплате и callback-выборе.
- [x] Админы из env сидируются один раз; дальше источник истины — БД.
- [x] Тесты страницы и действий.
**DoD:** изменения в веб-интерфейсе применяются к боту без рестарта.
## Порядок работ
1. Блок 1 (подготовка) — без него нельзя валидировать клиент и workflow.

95
db.py
View File

@ -121,6 +121,8 @@ class User:
referrer_id: int | None
first_paid_at: str | None
created_at: str
is_admin: bool
is_blocked: bool
@property
def display_user(self) -> str:
@ -136,6 +138,8 @@ class User:
referrer_id=row["referrer_id"],
first_paid_at=row["first_paid_at"],
created_at=row["created_at"],
is_admin=bool(row["is_admin"]),
is_blocked=bool(row["is_blocked"]),
)
@ -242,11 +246,18 @@ CREATE TABLE IF NOT EXISTS users (
last_daily_credit TEXT NOT NULL,
referrer_id INTEGER,
first_paid_at TEXT,
created_at TEXT NOT NULL
created_at TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
is_blocked INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_users_referrer
ON users(referrer_id);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT
);
"""
@ -295,6 +306,16 @@ class Database:
await conn.execute(
"ALTER TABLE pending_payments ADD COLUMN is_premium INTEGER NOT NULL DEFAULT 0"
)
async with conn.execute("PRAGMA table_info(users)") as cursor:
user_columns = {row["name"] async for row in cursor}
if "is_admin" not in user_columns:
await conn.execute(
"ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0"
)
if "is_blocked" not in user_columns:
await conn.execute(
"ALTER TABLE users ADD COLUMN is_blocked INTEGER NOT NULL DEFAULT 0"
)
await conn.execute(
"""
INSERT OR IGNORE INTO users
@ -415,6 +436,78 @@ class Database:
raise RuntimeError("Не удалось прочитать пользователя")
return user
async def seed_admins(self, user_ids: frozenset[int] | set[int]) -> None:
if not user_ids:
return
conn = self._connection()
async with self._write_lock:
async with conn.execute(
"SELECT value FROM app_settings WHERE key = 'admins_seeded'"
) as cursor:
if await cursor.fetchone() is not None:
return
today = msk_today().isoformat()
for user_id in user_ids:
await conn.execute(
"""
INSERT OR IGNORE INTO users
(user_id, username, credits, last_daily_credit, created_at)
VALUES (?, NULL, 3, ?, ?)
""",
(user_id, today, utc_now()),
)
await conn.execute(
"UPDATE users SET is_admin = 1 WHERE user_id = ?",
(user_id,),
)
await conn.execute(
"INSERT OR REPLACE INTO app_settings (key, value)"
" VALUES ('admins_seeded', '1')"
)
await conn.commit()
async def list_users_admin(self, limit: int = 500) -> list[User]:
conn = self._connection()
async with conn.execute(
"SELECT * FROM users ORDER BY user_id LIMIT ?", (limit,)
) as cursor:
rows = await cursor.fetchall()
return [User.from_row(row) for row in rows]
async def set_admin(self, user_id: int, enabled: bool) -> bool:
return await self._update_user_flag(
"is_admin", user_id, enabled
)
async def set_blocked(self, user_id: int, enabled: bool) -> bool:
return await self._update_user_flag(
"is_blocked", user_id, enabled
)
async def set_credits(self, user_id: int, credits: int) -> bool:
conn = self._connection()
async with self._write_lock:
cursor = await conn.execute(
"UPDATE users SET credits = ? WHERE user_id = ?",
(max(0, credits), user_id),
)
await conn.commit()
return cursor.rowcount > 0
async def _update_user_flag(
self, column: str, user_id: int, enabled: bool
) -> bool:
if column not in {"is_admin", "is_blocked"}:
raise ValueError("Недопустимое поле")
conn = self._connection()
async with self._write_lock:
cursor = await conn.execute(
f"UPDATE users SET {column} = ? WHERE user_id = ?",
(int(enabled), user_id),
)
await conn.commit()
return cursor.rowcount > 0
async def add_credits(self, user_id: int, amount: int) -> int | None:
conn = self._connection()
async with self._write_lock:

View File

@ -7,3 +7,4 @@ fastapi>=0.115,<1
uvicorn>=0.30,<1
jinja2>=3.1,<4
pillow>=10,<13
python-multipart>=0.0.9,<1

View File

@ -48,6 +48,31 @@ def insert_request(
connection.close()
def insert_user(
path: Path,
*,
user_id: int,
username: str | None = "alice",
credits: int = 3,
is_admin: int = 0,
is_blocked: int = 0,
) -> None:
connection = sqlite3.connect(path)
try:
connection.execute(
"""
INSERT INTO users
(user_id, username, credits, last_daily_credit, created_at,
is_admin, is_blocked)
VALUES (?, ?, ?, '2026-10-18', '2026-10-18T12:00:00+00:00', ?, ?)
""",
(user_id, username, credits, is_admin, is_blocked),
)
connection.commit()
finally:
connection.close()
def test_history_page_and_user_filter(tmp_path: Path) -> None:
settings = make_settings(tmp_path)
app = create_app(settings)
@ -112,3 +137,46 @@ def test_detail_and_media(tmp_path: Path) -> None:
missing = client.get(f"/media/{request_id}/input")
assert missing.status_code == 404
def test_user_management(tmp_path: Path) -> None:
settings = make_settings(tmp_path)
app = create_app(settings)
with TestClient(app) as client:
insert_user(settings.db_path, user_id=1, username="alice")
insert_user(settings.db_path, user_id=2, username="bob", credits=0)
page = client.get("/users")
assert page.status_code == 200
assert "@alice" in page.text
assert "@bob" in page.text
assert client.post("/users/1/credits", data={"credits": "9"}).status_code == 200
assert client.post("/users/1/admin").status_code == 200
assert client.post("/users/2/block").status_code == 200
connection = sqlite3.connect(settings.db_path)
try:
first = connection.execute(
"SELECT credits, is_admin, is_blocked FROM users WHERE user_id = 1"
).fetchone()
second = connection.execute(
"SELECT is_blocked FROM users WHERE user_id = 2"
).fetchone()
finally:
connection.close()
assert first == (9, 1, 0)
assert second == (1,)
def test_user_search(tmp_path: Path) -> None:
settings = make_settings(tmp_path)
app = create_app(settings)
with TestClient(app) as client:
insert_user(settings.db_path, user_id=1, username="alice")
insert_user(settings.db_path, user_id=2, username="bob")
page = client.get("/users?q=alice")
assert page.status_code == 200
assert "@alice" in page.text
assert "@bob" not in page.text

53
web.py
View File

@ -7,12 +7,12 @@ import os
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
from typing import Any
from typing import Annotated, Any
from urllib.parse import urlencode
from uuid import uuid4
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, HTMLResponse
from fastapi import FastAPI, Form, HTTPException, Request
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from PIL import Image, ImageOps
@ -96,6 +96,53 @@ def create_app(settings: Settings | None = None) -> FastAPI:
async def healthz() -> dict[str, bool]:
return {"ok": True}
@app.get("/users", response_class=HTMLResponse)
async def users(request: Request, q: str = ""):
db: Database = request.app.state.db
rows = await db.list_users_admin(1000)
needle = q.strip().lower().lstrip("@")
if needle:
rows = [
user
for user in rows
if needle in str(user.user_id)
or (user.username and needle in user.username.lower())
]
return templates.TemplateResponse(
request=request,
name="users.html",
context={"users": rows, "q": q},
)
@app.post("/users/{user_id}/credits")
async def user_credits(
request: Request,
user_id: int,
credits: Annotated[int, Form()],
) -> RedirectResponse:
db: Database = request.app.state.db
if not await db.set_credits(user_id, max(0, credits)):
raise HTTPException(status_code=404, detail="Пользователь не найден")
return RedirectResponse("/users", status_code=303)
@app.post("/users/{user_id}/admin")
async def user_admin(request: Request, user_id: int) -> RedirectResponse:
db: Database = request.app.state.db
user = await db.get_user(user_id)
if user is None:
raise HTTPException(status_code=404, detail="Пользователь не найден")
await db.set_admin(user_id, not user.is_admin)
return RedirectResponse("/users", status_code=303)
@app.post("/users/{user_id}/block")
async def user_block(request: Request, user_id: int) -> RedirectResponse:
db: Database = request.app.state.db
user = await db.get_user(user_id)
if user is None:
raise HTTPException(status_code=404, detail="Пользователь не найден")
await db.set_blocked(user_id, not user.is_blocked)
return RedirectResponse("/users", status_code=303)
@app.get("/", response_class=HTMLResponse)
async def index(
request: Request,

View File

@ -35,6 +35,25 @@ a:hover { text-decoration: underline; }
.brand { font-weight: 700; font-size: 18px; color: var(--text); }
.muted { color: var(--muted); font-size: 13px; }
.nav { display: flex; gap: 16px; margin-left: 8px; }
.nav a { color: var(--muted); font-size: 14px; }
.nav a:hover { color: var(--text); }
.page-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.search { display: flex; gap: 8px; }
.search input { min-width: 220px; }
.inline-form { display: inline-flex; gap: 6px; align-items: center; margin-top: 6px; }
.inline-form button { padding: 4px 8px; font-size: 12px; }
.credits-input { width: 80px; min-width: 0; padding: 4px 6px; }
.users-table { min-width: 1050px; }
.users-table td { vertical-align: top; }
.page { max-width: 1280px; margin: 0 auto; padding: 24px 28px 48px; }

View File

@ -9,6 +9,10 @@
<body>
<header class="topbar">
<a class="brand" href="/">MpakoImageAIbot</a>
<nav class="nav">
<a href="/">История</a>
<a href="/users">Пользователи</a>
</nav>
<span class="muted">История запросов</span>
</header>
<main class="page">

65
web/templates/users.html Normal file
View File

@ -0,0 +1,65 @@
{% extends "base.html" %}
{% block title %}Пользователи — MpakoImageAIbot{% endblock %}
{% block content %}
<div class="page-head">
<h1>Пользователи</h1>
<form class="search" method="get" action="/users">
<input name="q" value="{{ q }}" placeholder="ID или username">
<button type="submit">Найти</button>
</form>
</div>
<div class="table-wrap">
<table class="users-table">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Баланс</th>
<th>Админ</th>
<th>Блокировка</th>
<th>Реферер</th>
<th>Первая оплата</th>
<th>Создан</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td class="mono">
<a href="/?user_id={{ user.user_id }}">{{ user.user_id }}</a>
</td>
<td>{{ user.display_user }}</td>
<td>
<form class="inline-form" method="post" action="/users/{{ user.user_id }}/credits">
<input class="credits-input" type="number" name="credits" min="0" value="{{ user.credits }}">
<button type="submit">Сохранить</button>
</form>
</td>
<td>
{% if user.is_admin %}<span class="badge done">да</span>{% else %}<span class="badge">нет</span>{% endif %}
<form class="inline-form" method="post" action="/users/{{ user.user_id }}/admin">
<button type="submit">{{ "Снять" if user.is_admin else "Назначить" }}</button>
</form>
</td>
<td>
{% if user.is_blocked %}<span class="badge failed">заблокирован</span>{% else %}<span class="badge done">активен</span>{% endif %}
<form class="inline-form" method="post" action="/users/{{ user.user_id }}/block">
<button type="submit">{{ "Разблокировать" if user.is_blocked else "Заблокировать" }}</button>
</form>
</td>
<td>
{% if user.referrer_id %}
<a href="/users?q={{ user.referrer_id }}">{{ user.referrer_id }}</a>
{% else %}—{% endif %}
</td>
<td>{{ user.first_paid_at|fmt_dt }}</td>
<td>{{ user.created_at|fmt_dt }}</td>
</tr>
{% else %}
<tr><td colspan="8" class="empty">Пользователей нет</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}