diff --git a/README.md b/README.md index df7838f..18c3561 100644 --- a/README.md +++ b/README.md @@ -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`). - Фильтрация промпта выполняется до выставления счёта. - Если поставить задачу в очередь не удалось, звёзды возвращаются. diff --git a/bot.py b/bot.py index 5dd3b48..1a0eeff 100644 --- a/bot.py +++ b/bot.py @@ -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( diff --git a/checklist.md b/checklist.md index 5063a2f..a1481f8 100644 --- a/checklist.md +++ b/checklist.md @@ -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. diff --git a/db.py b/db.py index 89e68ce..a475d61 100644 --- a/db.py +++ b/db.py @@ -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: diff --git a/requirements.txt b/requirements.txt index 76f3e41..3e349ba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/tests/test_web.py b/tests/test_web.py index 5d664ac..083153a 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -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 diff --git a/web.py b/web.py index fa272ee..2de967c 100644 --- a/web.py +++ b/web.py @@ -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, diff --git a/web/static/style.css b/web/static/style.css index b28400e..b130c18 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -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; } diff --git a/web/templates/base.html b/web/templates/base.html index 9e6fc00..9818017 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -9,6 +9,10 @@
MpakoImageAIbot + История запросов
diff --git a/web/templates/users.html b/web/templates/users.html new file mode 100644 index 0000000..2d635c5 --- /dev/null +++ b/web/templates/users.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% block title %}Пользователи — MpakoImageAIbot{% endblock %} +{% block content %} +
+

Пользователи

+ +
+ +
+ + + + + + + + + + + + + + + {% for user in users %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
IDUsernameБалансАдминБлокировкаРеферерПервая оплатаСоздан
+ {{ user.user_id }} + {{ user.display_user }} +
+ + +
+
+ {% if user.is_admin %}да{% else %}нет{% endif %} +
+ +
+
+ {% if user.is_blocked %}заблокирован{% else %}активен{% endif %} +
+ +
+
+ {% if user.referrer_id %} + {{ user.referrer_id }} + {% else %}—{% endif %} + {{ user.first_paid_at|fmt_dt }}{{ user.created_at|fmt_dt }}
Пользователей нет
+
+{% endblock %}