Customer Service — API Contract
Единственный источник правды для API Customer Service.
Бэкенд реализует контракт. Фронтенд потребляет контракт. Отклонения запрещены.
(Введён в BR 3.1)
Содержание
Public API (Bearer JWT)
Клиенты
- GET /customers
- GET /customers/search
- GET /customers/{id}
- POST /customers
- PATCH /customers/{id}
- DELETE /customers/{id}
- POST /customers/{id}/merge
Адреса клиентов
- GET /customers/{id}/addresses
- POST /customers/{id}/addresses
- PATCH /customers/{id}/addresses/{address_id}
- DELETE /customers/{id}/addresses/{address_id}
Группы клиентов
- GET /customer-groups
- GET /customer-groups/{id}
- POST /customer-groups
- PATCH /customer-groups/{id}
- DELETE /customer-groups/{id}
- GET /customer-groups/{id}/members
- POST /customer-groups/{id}/members
- DELETE /customer-groups/{id}/members/{customer_id}
- POST /customer-groups/{id}/recompute
Internal API (X-Service-Token)
Общие конвенции
| Параметр | Значение |
|---|---|
| Base path | /api/v1 (public), /api/v1/internal (internal) |
| Content-Type | application/json |
| Auth (public) | Authorization: Bearer <JWT> |
| Auth (internal) | X-Service-Token: <token> |
| Ответ объект | { "data": { ... } } |
| Ответ список | { "data": [...], "meta": { "page", "per_page", "total" } } |
| Ошибка | { "error": { "code", "message", "details": [...] } } |
| Коды ошибок | UPPER_SNAKE_CASE |
Scope фильтрация: franchise_id достаётся из JWT. Customer Service не принимает franchise_id в query/body — все запросы автоматически ограничены франшизой вызывающего.
GET /customers
(BR 3.1)
Список клиентов франшизы с фильтрами, поиском, пагинацией.
| Параметр | Значение |
|---|---|
| Auth | Bearer JWT + permission customers.read |
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
search | string | no | Подстрока для поиска по ФИО / phone / email |
group_id | uuid | no | Фильтр: клиенты этой группы |
source | string | no | Фильтр по источнику (pos/web/mobile/admin/import) |
registered_from | date | no | Фильтр: зарегистрирован не раньше |
registered_to | date | no | Фильтр: зарегистрирован не позже |
page | integer | no | Default: 1 |
per_page | integer | no | Default: 20, max: 100 |
Response 200
{
"data": [
{
"id": "uuid",
"phone": "+79991234567",
"email": "user@example.com",
"first_name": "Иван",
"last_name": "Петров",
"birthday": "1990-06-15",
"gender": "male",
"registration_source": "pos",
"registered_at": "2026-04-01T10:30:00Z",
"groups": [
{ "id": "uuid", "name": "VIP", "type": "dynamic" }
],
"ltv": "34500.00",
"orders_count": 12,
"last_visit_at": "2026-04-18T14:20:00Z"
}
],
"meta": { "page": 1, "per_page": 20, "total": 150 }
}Примечание: поля ltv, orders_count, last_visit_at агрегируются на стороне Admin BFF (из Order Service) — Customer Service возвращает клиента без них, BFF добавляет. В контракте Customer Service возвращает без агрегаций; BFF-agregation описывается в Admin BFF контракте.
Errors
| Code | HTTP | Когда |
|---|---|---|
UNAUTHORIZED | 401 | Нет или невалидный JWT |
FORBIDDEN | 403 | Нет permission customers.read |
GET /customers/search
(BR 3.1)
Endpoint ещё не реализован
Сейчас поиск по телефону доступен только через internal-endpoint
GET /internal/customers/search-by-phone(X-Service-Token). Публичный/customers/searchсBearer JWT— planned. POS BFF ходит через internal.
Поиск клиента по телефону — для POS (кассир ищет перед созданием). Возвращает 0 или 1 результат.
| Параметр | Значение |
|---|---|
| Auth | Bearer JWT + permission customers.read ИЛИ customers.create_quick |
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
phone | string | yes | Телефон в любом формате — нормализуется в E.164 |
Response 200 (найден)
{
"data": {
"id": "uuid",
"phone": "+79991234567",
"first_name": "Иван",
"last_name": "Петров",
"birthday": "1990-06-15",
"groups": [{ "id": "uuid", "name": "VIP", "type": "dynamic" }],
"ltv": null,
"orders_count": null,
"last_visit_at": null
}
}Response 200 (не найден)
{ "data": null }Errors
| Code | HTTP | Когда |
|---|---|---|
VALIDATION_ERROR | 400 | Телефон не валидный после нормализации |
UNAUTHORIZED | 401 | |
FORBIDDEN | 403 | Нет нужного permission |
GET /customers/{id}
(BR 3.1)
Карточка клиента с адресами и группами.
| Параметр | Значение |
|---|---|
| Auth | Bearer JWT + permission customers.read |
Path Parameters
| Param | Type | Required | Description |
|---|---|---|---|
id | uuid | yes | ID клиента |
Response 200
{
"data": {
"id": "uuid",
"phone": "+79991234567",
"email": "user@example.com",
"first_name": "Иван",
"last_name": "Петров",
"birthday": "1990-06-15",
"gender": "male",
"notes": "Постоянный клиент",
"registration_source": "pos",
"registered_by_employee_id": "uuid",
"registered_at": "2026-04-01T10:30:00Z",
"consent_signed_at": "2026-04-01T10:30:00Z",
"addresses": [
{
"id": "uuid",
"city": "Москва",
"street": "Пушкина, 10",
"apartment": "25",
"entrance": "2",
"floor": "5",
"intercom": "1234",
"delivery_zone_id": "uuid",
"notes": null,
"is_default": true
}
],
"groups": [
{ "id": "uuid", "name": "VIP", "type": "dynamic" },
{ "id": "uuid", "name": "Клуб ценителей", "type": "static" }
]
}
}Errors
| Code | HTTP | Когда |
|---|---|---|
CUSTOMER_NOT_FOUND | 404 | Клиент не найден в scope франшизы или soft-deleted |
UNAUTHORIZED | 401 | |
FORBIDDEN | 403 |
POST /customers
(BR 3.1)
Создание клиента. Два режима:
- Полная форма — если у токена permission
customers.edit(админка: все поля) - Quick-create — если только
customers.create_quick(POS: минимум phone + first_name)
| Параметр | Значение |
|---|---|
| Auth | Bearer JWT + permission customers.edit ИЛИ customers.create_quick |
Request Body
{
"phone": "89991234567",
"email": "user@example.com",
"first_name": "Иван",
"last_name": "Петров",
"birthday": "1990-06-15",
"gender": "male",
"notes": "...",
"consent_signed": true,
"registration_source": "admin"
}phone— обязательно, нормализуется в E.164first_name— обязательно- Остальное — опционально
registration_source— если не передано, по умолчаниюadmin(админка) илиpos(POS BFF)consent_signed: true→consent_signed_at = now(), иначеNULL- В quick-режиме поля
last_name,gender,notes,birthday,emailигнорируются если не переданы
Response 201
{
"data": {
"id": "uuid",
"phone": "+79991234567",
"first_name": "Иван",
"...": "остальные поля"
}
}Errors
| Code | HTTP | Когда |
|---|---|---|
VALIDATION_ERROR | 400 | phone невалидный / first_name пустой / email невалидный |
CUSTOMER_PHONE_TAKEN | 409 | Клиент с этим телефоном уже существует во франшизе (включая soft-deleted — нельзя пересоздать пока не восстановлен или не обезличен — обезличенный phone другой) |
UNAUTHORIZED | 401 | |
FORBIDDEN | 403 |
PATCH /customers/{id}
(BR 3.1)
Редактирование клиента. Можно менять все поля кроме id, franchise_id, registration_source, registered_by_employee_id, registered_at.
| Параметр | Значение |
|---|---|
| Auth | Bearer JWT + permission customers.edit |
Path Parameters
| Param | Type | Required | Description |
|---|---|---|---|
id | uuid | yes |
Request Body
Любое подмножество полей (аналогично POST). При смене phone — проверяется уникальность.
Response 200
Обновлённый клиент (как в GET /customers/{id}).
Errors
| Code | HTTP | Когда |
|---|---|---|
CUSTOMER_NOT_FOUND | 404 | |
CUSTOMER_PHONE_TAKEN | 409 | Новый phone занят |
VALIDATION_ERROR | 400 | |
UNAUTHORIZED | 401 | |
FORBIDDEN | 403 |
DELETE /customers/{id}
(BR 3.1)
Soft delete + анонимизация PII. Физическое удаление невозможно (есть FK из Order Service).
| Параметр | Значение |
|---|---|
| Auth | Bearer JWT + permission customers.delete |
Response 204
Пустой ответ.
Errors
| Code | HTTP | Когда |
|---|---|---|
CUSTOMER_NOT_FOUND | 404 | |
UNAUTHORIZED | 401 | |
FORBIDDEN | 403 | Нет customers.delete |
Поведение: атомарно в одной транзакции — deleted_at = now(), PII обнуляются (см. Data Model.md § Анонимизация), адреса удаляются (CASCADE), членство в группах удаляется. Связь orders.customer_id сохраняется. Публикуется событие customer.deleted.
POST /customers/{id}/merge
(BR 3.1)
Объединение дубликатов. Целевой клиент (id из path) остаётся; источник (source_id из body) soft-deleted с переносом адресов и связей.
| Параметр | Значение |
|---|---|
| Auth | Bearer JWT + permission customers.edit + только владелец франшизы (scope all_franchise) |
Request Body
{ "source_id": "uuid" }Response 200
Обновлённый target клиент с уже перенесёнными адресами и группами.
Errors
| Code | HTTP | Когда |
|---|---|---|
CUSTOMER_NOT_FOUND | 404 | Target или source не найден |
MERGE_SAME_CUSTOMER | 400 | source_id == id |
FORBIDDEN | 403 | Не владелец франшизы |
Поведение: атомарно в одной транзакции — адреса source → target (с сохранением default логики, если default конфликтует — source.default обнуляется), customer_group_members source переносятся target (без дублей), source получает deleted_at + anonymize. На стороне Order Service orders.customer_id = source_id не мигрируются (в MVP) — история source остаётся под обезличенным именем. В будущих итерациях — добавить internal event customer.merged и Order Service мигрирует FK.
GET /customers/{id}/addresses
(BR 3.1)
Список адресов клиента.
| Параметр | Значение |
|---|---|
| Auth | Bearer JWT + permission customers.read |
Response 200
{
"data": [
{
"id": "uuid",
"city": "Москва",
"street": "Пушкина, 10",
"apartment": "25",
"entrance": "2",
"floor": "5",
"intercom": "1234",
"delivery_zone_id": "uuid",
"notes": null,
"is_default": true
}
]
}POST /customers/{id}/addresses
(BR 3.1)
Добавить адрес клиенту.
| Параметр | Значение |
|---|---|
| Auth | Bearer JWT + permission customers.edit |
Request Body
{
"city": "Москва",
"street": "Пушкина, 10",
"apartment": "25",
"entrance": "2",
"floor": "5",
"intercom": "1234",
"delivery_zone_id": "uuid",
"notes": null,
"is_default": true
}Поля city, street обязательны. Остальные — nullable. is_default по умолчанию false. Если is_default=true — предыдущий default клиента снимается.
Response 201
Созданный адрес.
Errors
| Code | HTTP | Когда |
|---|---|---|
CUSTOMER_NOT_FOUND | 404 | |
VALIDATION_ERROR | 400 |
PATCH /customers/{id}/addresses/{address_id}
(BR 3.1)
Редактирование адреса. Любое подмножество полей. Переключение is_default=true → старый default снимается.
| Параметр | Значение |
|---|---|
| Auth | Bearer JWT + permission customers.edit |
Response 200
Обновлённый адрес.
Errors
| Code | HTTP | Когда |
|---|---|---|
CUSTOMER_NOT_FOUND | 404 | |
ADDRESS_NOT_FOUND | 404 |
DELETE /customers/{id}/addresses/{address_id}
(BR 3.1)
Удаление адреса (физическое, не soft).
| Параметр | Значение |
|---|---|
| Auth | Bearer JWT + permission customers.edit |
Response 204
GET /customer-groups
(BR 3.1)
Список групп клиентов франшизы.
| Параметр | Значение |
|---|---|
| Auth | Bearer JWT + permission customer_groups.read |
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
type | string | no | static / dynamic — фильтр |
search | string | no | Подстрока названия |
page | integer | no | Default: 1 |
per_page | integer | no | Default: 20 |
Response 200
{
"data": [
{
"id": "uuid",
"name": "VIP",
"description": "...",
"type": "dynamic",
"members_count": 42,
"last_recomputed_at": "2026-04-20T03:15:00Z",
"created_at": "2026-04-01T10:00:00Z"
}
],
"meta": { "page": 1, "per_page": 20, "total": 8 }
}GET /customer-groups/{id}
(BR 3.1)
Карточка группы с правилами (для dynamic) и кол-вом участников.
| Параметр | Значение |
|---|---|
| Auth | Bearer JWT + permission customer_groups.read |
Response 200
{
"data": {
"id": "uuid",
"name": "VIP",
"description": "...",
"type": "dynamic",
"rules_json": {
"version": 1,
"rules": [
{ "type": "total_spent_all_time", "operator": "gte", "amount": 50000 }
]
},
"members_count": 42,
"last_recomputed_at": "2026-04-20T03:15:00Z",
"created_at": "2026-04-01T10:00:00Z",
"created_by": "uuid"
}
}Для type=static — rules_json: null.
Errors
| Code | HTTP | Когда |
|---|---|---|
GROUP_NOT_FOUND | 404 |
POST /customer-groups
(BR 3.1)
Создание группы.
| Параметр | Значение |
|---|---|
| Auth | Bearer JWT + permission customer_groups.edit |
Request Body
{
"name": "VIP",
"description": "...",
"type": "dynamic",
"rules_json": {
"version": 1,
"rules": [
{ "type": "total_spent_all_time", "operator": "gte", "amount": 50000 }
]
}
}- Для
type=static:rules_jsonне передаётся (илиnull) - Для
type=dynamic:rules_jsonобязателен, массив 1-5 правил
Response 201
Созданная группа.
Errors
| Code | HTTP | Когда |
|---|---|---|
VALIDATION_ERROR | 400 | Невалидная структура rules_json / пустой name |
GROUP_NAME_TAKEN | 409 | Группа с таким именем уже есть |
PATCH /customer-groups/{id}
(BR 3.1)
Редактирование группы. Меняются name, description, rules_json (только для dynamic).
type неизменяем — нельзя конвертировать static ↔ dynamic после создания (создайте новую группу).
Изменение rules_json триггерит пересчёт dynamic группы.
Errors
| Code | HTTP | Когда |
|---|---|---|
GROUP_NOT_FOUND | 404 | |
GROUP_NAME_TAKEN | 409 | |
VALIDATION_ERROR | 400 | Попытка изменить type или невалидные правила |
DELETE /customer-groups/{id}
(BR 3.1)
Soft delete группы. Членство в истории остаётся, но группа не отображается.
| Параметр | Значение |
|---|---|
| Auth | Bearer JWT + permission customer_groups.edit |
Response 204
GET /customer-groups/{id}/members
(BR 3.1)
Список клиентов в группе с пагинацией.
Response 200
{
"data": [
{
"customer_id": "uuid",
"added_at": "2026-04-15T10:00:00Z",
"customer": {
"id": "uuid",
"phone": "+79991234567",
"first_name": "Иван",
"last_name": "Петров"
}
}
],
"meta": { "page": 1, "per_page": 50, "total": 42 }
}POST /customer-groups/{id}/members
(BR 3.1)
Добавить клиентов в статическую группу. Только для type=static.
Request Body
{ "customer_ids": ["uuid", "uuid"] }Response 200
{ "data": { "added": 2, "already_in": 0 } }Errors
| Code | HTTP | Когда |
|---|---|---|
GROUP_WRONG_TYPE | 400 | Группа type=dynamic — членством управляют правила |
GROUP_NOT_FOUND | 404 |
DELETE /customer-groups/{id}/members/{customer_id}
(BR 3.1)
Удалить клиента из статической группы.
Errors
| Code | HTTP | Когда |
|---|---|---|
GROUP_WRONG_TYPE | 400 | Для dynamic нельзя вручную удалять |
GROUP_NOT_FOUND | 404 |
POST /customer-groups/{id}/recompute
(BR 3.1)
Триггер ручного пересчёта динамической группы. Только для type=dynamic.
| Параметр | Значение |
|---|---|
| Auth | Bearer JWT + permission customer_groups.edit |
Response 202
Без тела. Фактический пересчёт — асинхронный. Пользователь увидит обновлённый last_recomputed_at и members_count при последующем GET группы.
Текущая реализация
CustomerGroupController.recompute()возвращает202 Acceptedс пустым body. Подробный{ group_id, status, started_at }payload — planned.
Errors
| Code | HTTP | Когда |
|---|---|---|
GROUP_WRONG_TYPE | 400 | Для static нельзя |
GROUP_NOT_FOUND | 404 |
Internal API
Service Token
Все endpoints в этой секции требуют
X-Service-Token(shared secret). JWT не обрабатывается.
GET /internal/customers/{id}
(BR 3.1)
Endpoint ещё не реализован
InternalCustomerControllerимеет толькоsearch-by-phone,quick-createи recompute-команды. Cross-service lookup по ID planned — до его реализации потребители дёргают publicGET /customers/{id}с service-role JWT.
Получить клиента для cross-service сценариев (Order Service, Loyalty Service в будущем).
Response 200
{
"data": {
"id": "uuid",
"franchise_id": "uuid",
"phone": "+79991234567",
"first_name": "Иван",
"last_name": "Петров",
"deleted_at": null
}
}Возвращает также soft-deleted (с обезличенными PII) — downstream-сервис решает что делать.
Errors
| Code | HTTP | Когда |
|---|---|---|
CUSTOMER_NOT_FOUND | 404 | |
UNAUTHORIZED | 401 | Нет или невалидный X-Service-Token |
POST /internal/customer-groups/recompute-for-customer
(BR 3.1)
Точечный пересчёт всех dynamic групп одной франшизы для одного клиента.
Используется:
- Kafka consumer’ом Customer Service при
order.completedиcustomer.updated - Админкой через
POST /customer-groups/{id}/recompute(для одной группы) и через будущий handle (для одного клиента при смене данных)
Request Body
{ "customer_id": "uuid" }Response 200
{
"data": {
"customer_id": "uuid",
"added_to": ["group_id_1"],
"removed_from": ["group_id_2"],
"recomputed_at": "2026-04-20T10:30:00Z"
}
}Errors
| Code | HTTP | Когда |
|---|---|---|
CUSTOMER_NOT_FOUND | 404 | |
UNAUTHORIZED | 401 |
Ссылки
- Overview
- Data Model
- Events
- Order Service API — customer_id в orders
- Бизнес-спека: Клиенты
- Бизнес-спека: Группы клиентов
- BR 3.1