feature: API-ключи клиентов и журнал использования в Postgres (#4, #17)

- Postgres в compose; пакет db (SQLAlchemy async): clients, windows, requests.
- Все эндпоинты, кроме /health, — только с ключом в X-API-Key, иначе 401; без
  выпущенных ключей доступа нет. В базе — только sha256 ключа.
- Задача и окно сессии привязаны к клиенту: чужой ключ их не видит.
- Воркер пишет журнал: окно (открыто, последнее обращение, закрыто) и задачу
  (в очереди, начало, конец, итог, данные без секретов). Сбой записи не роняет задачу.
- python -m db.cli create|list|revoke|stats; make key-create/key-list/key-revoke/stats.
- smoke и loadtest с ключом.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
protokeyandClaude Opus 5.5 committed 2026-10-13 21:48:48 +04:00
1 parent 9dc2c0b619
commit 1a4bd8d672
34 files changed
+857 -81

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
"""
Postgres (#4, #17): клиенты с API-ключами и журнал работы — окна браузера и запросы.
Общий для api и worker. Схема создаётся при старте сервиса (`init_db`). `DATABASE_URL` —
`postgresql+asyncpg://...`; в тестах — `sqlite+aiosqlite://...`.
"""
+67
View File
@@ -0,0 +1,67 @@
"""
Клиенты и API-ключи:
python -m db.cli create <name> # выпустить ключ: печатается один раз
python -m db.cli list
python -m db.cli revoke <id>
python -m db.cli stats [--days 7] # запросы, их время и время работы браузера по клиентам
В compose — через make: make key-create name=..., make key-list, make key-revoke id=..., make stats days=...
"""
import argparse
import asyncio
from db import clients, stats
from db.engine import connect
async def run(args) -> None:
db = connect()
try:
await db.init()
if args.command == "create":
client, key = await clients.create_client(db, args.name)
print(f"client {client.id} ({client.name}), API key — сохраните, больше он не показывается:\n{key}")
elif args.command == "list":
for c in await clients.list_clients(db):
state = f"revoked {c.revoked_at:%Y-%m-%d %H:%M}" if c.revoked_at else "active"
last = f"{c.last_access_at:%Y-%m-%d %H:%M}" if c.last_access_at else "never"
print(f"{c.id:>4} {c.key_prefix}… {state:<24} last access {last:<16} {c.name}")
elif args.command == "revoke":
print("revoked" if await clients.revoke(db, args.id) else "no active client with this id")
elif args.command == "stats":
print_stats(await stats.usage(db, stats.since_days(args.days)), args.days)
finally:
await db.close()
def _ms(value: int | None) -> str:
return "—" if value is None else f"{value / 1000:.1f}s"
def print_stats(rows: list, days: float) -> None:
print(f"за {days:g} дн.")
print(f"{'клиент':<24} {'запросов':>8} {'ok':>6} {'ср.':>7} {'p95':>7} {'окон':>5} {'браузер, ч':>10} ошибки")
for u in rows:
errors = ", ".join(f"{k} {v}" for k, v in sorted(u.errors.items())) or "—"
name = f"{u.client_id or '-'} {u.name}"[:24]
print(
f"{name:<24} {u.requests:>8} {u.ok:>6} {_ms(u.avg_ms):>7} {_ms(u.p95_ms):>7} "
f"{u.windows:>5} {u.browser_seconds / 3600:>10.2f} {errors}"
)
if not rows:
print("запросов не было")
def main() -> None:
parser = argparse.ArgumentParser(prog="python -m db.cli", description="Клиенты Robotex и их API-ключи")
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("create", help="выпустить ключ новому клиенту").add_argument("name")
sub.add_parser("list", help="список клиентов")
sub.add_parser("revoke", help="отозвать ключ").add_argument("id", type=int)
sub.add_parser("stats", help="статистика использования по клиентам").add_argument("--days", type=float, default=7)
asyncio.run(run(parser.parse_args()))
if __name__ == "__main__":
main()
+48
View File
@@ -0,0 +1,48 @@
"""Клиенты и API-ключи (#4). Ключ показывается один раз при выпуске, в базе — только sha256."""
import hashlib
import secrets
from sqlalchemy import select, update
from db.engine import Database
from db.models import Client, utcnow
KEY_PREFIX = "rbx_"
def hash_key(key: str) -> str:
return hashlib.sha256(key.encode()).hexdigest()
async def create_client(db: Database, name: str) -> tuple[Client, str]:
key = KEY_PREFIX + secrets.token_urlsafe(32)
client = Client(name=name, key_hash=hash_key(key), key_prefix=key[:12])
async with db.session() as session, session.begin():
session.add(client)
return client, key
async def authenticate(db: Database, key: str | None) -> Client | None:
"""Клиент по ключу, если ключ не отозван; отмечает время последнего обращения."""
if not key:
return None
async with db.session() as session, session.begin():
client = await session.scalar(
select(Client).where(Client.key_hash == hash_key(key), Client.revoked_at.is_(None))
)
if client:
client.last_access_at = utcnow()
return client
async def revoke(db: Database, client_id: int) -> bool:
async with db.session() as session, session.begin():
result = await session.execute(
update(Client).where(Client.id == client_id, Client.revoked_at.is_(None)).values(revoked_at=utcnow())
)
return result.rowcount > 0
async def list_clients(db: Database) -> list[Client]:
async with db.session() as session:
return list(await session.scalars(select(Client).order_by(Client.id)))
+30
View File
@@ -0,0 +1,30 @@
import os
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
from db.models import Base
DEFAULT_URL = "postgresql+asyncpg://robotex:robotex@localhost:5432/robotex"
# api и worker создают схему при старте одновременно — в Postgres через advisory lock
_SCHEMA_LOCK = 780078
class Database:
def __init__(self, engine: AsyncEngine):
self.engine = engine
self.session = async_sessionmaker(engine, expire_on_commit=False)
async def init(self) -> None:
"""Создать недостающие таблицы."""
async with self.engine.begin() as conn:
if conn.dialect.name == "postgresql":
await conn.execute(text(f"SELECT pg_advisory_xact_lock({_SCHEMA_LOCK})"))
await conn.run_sync(Base.metadata.create_all)
async def close(self) -> None:
await self.engine.dispose()
def connect(url: str | None = None) -> Database:
return Database(create_async_engine(url or os.getenv("DATABASE_URL", DEFAULT_URL), pool_pre_ping=True))
+69
View File
@@ -0,0 +1,69 @@
"""
Журнал воркера (#17): окна браузера и задачи. Ошибка записи не роняет задачу — только предупреждение в лог.
"""
import logging
from datetime import datetime, timezone
from sqlalchemy import update
from db.engine import Database
from db.models import Request, Window, utcnow
log = logging.getLogger(__name__)
class Journal:
def __init__(self, db: Database | None):
self.db = db
async def _write(self, what: str, fn):
if self.db is None:
return None
try:
async with self.db.session() as session, session.begin():
return await fn(session)
except Exception:
log.warning("journal: %s failed", what, exc_info=True)
return None
async def window_opened(self, client_id: int | None, session_id: str, task_id: str, worker_id: str) -> int | None:
async def write(session):
window = Window(client_id=client_id, session_id=session_id, task_id=task_id, worker_id=worker_id)
session.add(window)
await session.flush()
return window.id
return await self._write("window opened", write)
async def window_used(self, window_id: int | None) -> None:
if window_id is not None:
await self._write("window used", lambda s: s.execute(
update(Window).where(Window.id == window_id).values(last_access_at=utcnow())
))
async def window_closed(self, window_id: int | None) -> None:
if window_id is not None:
await self._write("window closed", lambda s: s.execute(
update(Window).where(Window.id == window_id).values(closed_at=utcnow())
))
async def request_started(
self, task_id: str, client_id: int | None, session_id: str, api: str,
created_at: float | None, task_data: dict,
) -> None:
created = datetime.fromtimestamp(created_at, timezone.utc) if created_at else None
async def write(session):
session.add(Request(
task_id=task_id, client_id=client_id, session_id=session_id, api=api,
created_at=created, task_data=task_data,
))
await self._write("request started", write)
async def request_finished(self, task_id: str, window_id: int | None, result: dict | None) -> None:
"""result — ответ задачи; None — задачу не удалось выполнить (status failed)."""
values = {"finished_at": utcnow(), "window_id": window_id, "status": "failed"}
if result is not None:
values.update(status=result.get("status"), code=result.get("code"), duration_ms=result.get("duration_ms"))
await self._write("request finished", lambda s: s.execute(
update(Request).where(Request.task_id == task_id).values(**values)
))
+69
View File
@@ -0,0 +1,69 @@
"""
Таблицы:
* `clients` — клиенты API («Users» в #17): ключ (только sha256), время создания и последнего обращения.
* `windows` — окна браузера: когда открыто и закрыто, последнее обращение, клиент, задача, открывшая окно.
Из них считается время, которое браузер был активен.
* `requests` — задачи: создание (постановка в очередь), начало и конец выполнения, итог, данные задачи без
секретов (прокси, ключ 2captcha, вводимые значения и значения cookies не пишутся).
"""
from datetime import datetime, timezone
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
def utcnow() -> datetime:
return datetime.now(timezone.utc)
class Base(DeclarativeBase):
type_annotation_map = {datetime: DateTime(timezone=True)}
class Client(Base):
__tablename__ = "clients"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(200))
# sha256 ключа: сам ключ показывается один раз при выпуске и нигде не хранится
key_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
# начало ключа — чтобы отличать ключи в списке
key_prefix: Mapped[str] = mapped_column(String(16))
created_at: Mapped[datetime] = mapped_column(default=utcnow)
last_access_at: Mapped[datetime | None]
revoked_at: Mapped[datetime | None]
class Window(Base):
__tablename__ = "windows"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
client_id: Mapped[int | None] = mapped_column(ForeignKey("clients.id"), index=True)
session_id: Mapped[str] = mapped_column(String(36), index=True)
# задача, открывшая окно
task_id: Mapped[str] = mapped_column(String(36))
worker_id: Mapped[str] = mapped_column(String(100))
started_at: Mapped[datetime] = mapped_column(default=utcnow)
last_access_at: Mapped[datetime] = mapped_column(default=utcnow)
closed_at: Mapped[datetime | None]
class Request(Base):
__tablename__ = "requests"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
task_id: Mapped[str] = mapped_column(String(36), unique=True)
client_id: Mapped[int | None] = mapped_column(ForeignKey("clients.id"), index=True)
session_id: Mapped[str] = mapped_column(String(36), index=True)
window_id: Mapped[int | None] = mapped_column(ForeignKey("windows.id"))
# api: /v1/solve или /v2/tasks
api: Mapped[str] = mapped_column(String(8))
created_at: Mapped[datetime | None]
started_at: Mapped[datetime] = mapped_column(default=utcnow, index=True)
finished_at: Mapped[datetime | None]
duration_ms: Mapped[int | None]
# ok / err / failed; code — как в ответе (challenge_failed, action_failed, ...)
status: Mapped[str | None] = mapped_column(String(16))
code: Mapped[str | None] = mapped_column(String(32))
task_data: Mapped[dict] = mapped_column(JSON)
+77
View File
@@ -0,0 +1,77 @@
"""Статистика использования API (#17): число запросов, их время и время, которое был открыт браузер."""
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from sqlalchemy import select
from db.engine import Database
from db.models import Client, Request, Window, utcnow
@dataclass
class ClientUsage:
client_id: int | None
name: str
requests: int = 0
ok: int = 0
# по code: challenge_failed, action_failed, request_failed; failed — задачу не выполнили
errors: dict[str, int] = field(default_factory=dict)
durations_ms: list[int] = field(default_factory=list)
windows: int = 0
browser_seconds: float = 0.0
@property
def avg_ms(self) -> int | None:
return int(sum(self.durations_ms) / len(self.durations_ms)) if self.durations_ms else None
@property
def p95_ms(self) -> int | None:
if not self.durations_ms:
return None
ordered = sorted(self.durations_ms)
return ordered[min(len(ordered) - 1, int(len(ordered) * 0.95))]
def _aware(dt: datetime) -> datetime:
# sqlite отдаёт время без зоны
return dt if dt.tzinfo else dt.replace(tzinfo=utcnow().tzinfo)
async def usage(db: Database, since: datetime) -> list[ClientUsage]:
"""По клиентам за период: запросы, начатые после since, и время окон в пределах периода."""
now = utcnow()
async with db.session() as session:
names = {c.id: c.name for c in await session.scalars(select(Client))}
requests = list(await session.scalars(select(Request).where(Request.started_at >= since)))
windows = list(await session.scalars(
select(Window).where((Window.closed_at.is_(None)) | (Window.closed_at >= since))
))
result: dict[int | None, ClientUsage] = {}
def entry(client_id):
return result.setdefault(client_id, ClientUsage(client_id, names.get(client_id, "—")))
for r in requests:
u = entry(r.client_id)
u.requests += 1
if r.status == "ok":
u.ok += 1
elif r.finished_at is not None:
key = r.code or r.status or "unknown"
u.errors[key] = u.errors.get(key, 0) + 1
if r.duration_ms is not None:
u.durations_ms.append(r.duration_ms)
for w in windows:
u = entry(w.client_id)
u.windows += 1
start = max(_aware(w.started_at), since)
end = _aware(w.closed_at) if w.closed_at else now
u.browser_seconds += max(0.0, (end - start).total_seconds())
return sorted(result.values(), key=lambda u: -u.requests)
def since_days(days: float) -> datetime:
return utcnow() - timedelta(days=days)