Files
robotex/src/db/cli.py
T
protokeyandClaude Opus 5.5 1a4bd8d672 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>
2026-10-13 21:48:48 +04:00

68 lines
2.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Клиенты и 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()