- 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>
68 lines
2.7 KiB
Python
68 lines
2.7 KiB
Python
"""
|
||
Клиенты и 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()
|