docs: G6 дизайн — публичный read-API сделок по API-ключу
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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` | новый |
|
||||
Reference in New Issue
Block a user