docs: G6 дизайн — публичный read-API сделок по API-ключу

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-19 10:07:11 +03:00
parent c049ab49b6
commit abee37524e
@@ -0,0 +1,154 @@
# G6 — публичный read-API сделок по API-ключу — дизайн
**Дата:** 19.06.2026
**Статус:** дизайн утверждён владельцем (брейншторм)
**Находка:** G6 (go-live). API-ключи тенанта есть (`ApiKeyController`), но потребляющего endpoint'а нет — ключ декоративен.
**Граница:** backend. Без UI-правок, без write, без рейт-лимита (MVP).
---
## 1. Цель
Дать клиенту (тенанту) программный read-доступ к его **сделкам** (`deals`) по
выданному API-ключу — чтобы он мог выгружать свои сделки в свою систему/CRM.
Тогда API-ключ перестаёт быть декорацией.
**Терминология (выверено по схеме, не путать):**
- **Лиды** = `supplier_leads` — сырой приход от поставщика (webhook payload,
платформа B1/B2/B3/DIRECT, vid). Внутреннее/поставщицкое, **без** `tenant_id`.
Наружу клиенту НЕ отдаём.
- **Сделки** = `deals` — клиентская сущность в Лидерре (`tenant_id`, телефон,
статус, имя, город, проект…). **Их и отдаёт публичный API.**
---
## 2. Готовая инфраструктура (не меняем)
- `api_keys` (схема): `tenant_id`, `key_hash` (bcrypt, UNIQUE), `key_prefix`
(VARCHAR(10)), `scopes` (JSONB, дефолт `["read"]`), `last_used_at`,
`last_used_ip` (INET), `expires_at` (NOT NULL, дефолт +365д), `is_active`.
RLS — tenant isolation; индекс `idx_api_keys_tenant ON api_keys(tenant_id) WHERE is_active`.
- Генерация (`ApiKeyController::regenerate`): ключ = `lpkapi_` + `Str::random(48)`;
в БД — `key_hash = Hash::make(plain)`, `key_prefix = substr(plain, 0, 10)`;
один активный ключ на тенанта; scope `["read"]`; показывается один раз.
- Сделки (`deals`, партиционирована по `received_at`): поля для контракта —
`id`, `received_at`, `phone`, `contact_name`, `city`, `status`, `project_id`.
Стоимости на сделке нет (цена — биллинг-списание; вне MVP).
---
## 3. Архитектура
Три новых юнита:
### 3.1. Middleware `App\Http\Middleware\ApiKeyAuth`
Аутентификация входящего запроса по ключу:
1. Читает `Authorization: Bearer <key>`. Нет заголовка / не Bearer / пусто → `401`.
2. `prefix = substr($key, 0, 10)`.
3. Кандидаты: `ApiKey::on('pgsql_supplier')->where('key_prefix', $prefix)
->where('is_active', true)->where('expires_at', '>', now())->get()`.
**`pgsql_supplier` (BYPASSRLS)** — публичный роут не ставит tenant-GUC, под RLS
запрос к `api_keys` вернул бы пусто (паттерн RegistrationService).
4. По кандидатам: первый, у кого `Hash::check($key, $candidate->key_hash)` — матч.
Нет матча → `401`.
5. Scope: если `'read'` не в `$candidate->scopes` → `403`.
6. Успех: обновить `last_used_at = now()`, `last_used_ip = $request->ip()` (через
`pgsql_supplier`); положить `tenant_id` в `$request->attributes` (ключ
`api_tenant_id`). `next($request)`.
Алиас `apikey` регистрируется в `bootstrap/app.php` (`$middleware->alias([...])`).
Все ответы middleware — JSON `{ "message": "..." }` с нужным кодом.
### 3.2. Контроллер `App\Http\Controllers\Api\V1\DealsController@index`
`GET /api/v1/deals`:
- `tenantId = (int) $request->attributes->get('api_tenant_id')`.
- Параметры: `limit` (1..500, дефолт 100), `since` (nullable date → `received_at >=`),
`cursor` (opaque base64 `{r:received_at, i:id}`, keyset).
- Внутри `DB::transaction`: `SET LOCAL app.current_tenant_id = <tenantId>` (RLS) +
явный `->where('tenant_id', $tenantId)` (defense-in-depth, как в сессионном
DealController). Партиция по `received_at` уже существует.
- Запрос: `Deal::query()->where('tenant_id',$tenantId)->whereNull('deleted_at')`
(+`since` если задан, +keyset `(received_at,id) < (cursor)` если задан),
`orderByDesc('received_at')->orderByDesc('id')->limit($limit + 1)`;
`with('project:id,name')`.
- Ответ: `{ "data": [ {id, received_at, phone, contact_name, city, status,
project: <name|null>} ... ], "next_cursor": <base64|null> }`.
`next_cursor` строится из последней строки при наличии (`limit+1` trick).
- Битый cursor (не base64 / нет полей r,i) → `422` JSON.
### 3.3. Роут
В `routes/web.php` — новая группа:
```php
Route::middleware('apikey')->prefix('/api/v1')->group(function () {
Route::get('/deals', 'App\Http\Controllers\Api\V1\DealsController@index')->name('api.v1.deals.index');
});
```
Без `auth:sanctum`/`tenant` middleware (аутентификация и tenant-контекст —
внутри `ApiKeyAuth`/контроллера).
---
## 4. Обработка ошибок
| Ситуация | Код | Тело |
|---|---|---|
| Нет/не-Bearer/пустой ключ | 401 | `{"message":"Требуется API-ключ."}` |
| Ключ не найден/просрочен/неактивен | 401 | `{"message":"Неверный или неактивный API-ключ."}` |
| Нет scope `read` | 403 | `{"message":"Недостаточно прав ключа."}` |
| Битый cursor | 422 | `{"message":"Невалидный cursor."}` |
---
## 5. Критерий приёмки (Pest)
`app/tests/Feature/Api/V1/PublicDealsApiTest.php`
(`uses(DatabaseTransactions::class, SharesSupplierPdo::class)`):
Хелпер: создать активный ключ с известным plain —
`$plain = 'lpkapi_'.Str::random(48); ApiKey::create([... key_hash=Hash::make($plain),
key_prefix=substr($plain,0,10), scopes=['read'], expires_at=now()->addYear(),
is_active=true ...])`.
1. **Валидный ключ → 200 + свои сделки.** Тенант A с ключом и 2 сделками,
тенант B с 1 сделкой → `GET /api/v1/deals` с ключом A: 200, ровно 2 элемента,
сделка B не видна (изоляция).
2. **Нет заголовка → 401.**
3. **Неверный ключ → 401** (правильный префикс, но `Hash::check` не проходит).
4. **Просроченный ключ → 401** (`expires_at` в прошлом).
5. **Неактивный ключ → 401** (`is_active=false`).
6. **`last_used_at` обновляется** после успешного запроса (был null → не null).
7. **`since`-фильтр**: сделка старше `since` не попадает в ответ.
Все — GREEN на `composer --working-dir=app test`.
---
## 6. YAGNI / границы
- Только сделки (без проектов/баланса/статистики).
- Только read (write/delete — нет).
- Без рейт-лимита (можно добавить позже отдельной находкой).
- Без `cost` на сделке (стоимость — биллинг-списание, отдельная привязка; F2).
- Без UI-правок и без публичной документации эндпоинта (мелкий follow-up, если
владелец захочет описание для клиентов).
- Scope-модель не расширяем — все ключи уже `["read"]`.
---
## 7. Затрагиваемые файлы
| Файл | Действие |
|---|---|
| `app/app/Http/Middleware/ApiKeyAuth.php` | новый |
| `app/app/Http/Controllers/Api/V1/DealsController.php` | новый |
| `app/bootstrap/app.php` | +алиас middleware `apikey` |
| `app/routes/web.php` | +группа `/api/v1` |
| `app/tests/Feature/Api/V1/PublicDealsApiTest.php` | новый |