Маппинг Legacy CRM → Новая архитектура
Полная карта соответствия полей Legacy CRM API (book_orders) → Bounded Contexts, ACL-адаптеры, ID-маппинг и стратегия конвертации данных.
Маппинг Legacy CRM → Новая архитектура
Этот документ описывает конкретный план декомпозиции Legacy CRM (монолит с GOD-объектом book_orders) в новую микросервисную архитектуру. Каждое поле Legacy API замаплено на конкретный Bounded Context и сущность.
Источник: Анализ
old-crm-api.yaml(OpenAPI 3.1, CRM-API v1.0.2).
Типичная ситуация на рынке: Большинство российских ISP работают с самописными CRM на PHP/Delphi или устаревшими версиями UTM/LANBilling, где вся информация о клиенте (ФИО, паспорт, адрес, тариф, MAC-адрес, порт OLT, баланс, история платежей) хранится в одной таблице-«монстре» с 50–100 полями. При миграции критически важно не пытаться мигрировать данные «одним скриптом», а использовать Anti-Corruption Layer (ACL) для постепенной конвертации и ID Mapping Service для поддержания связности между старыми int32 ID и новыми UUID.
Обзор Legacy API
Эндпоинты
| Метод | Путь | Описание | Маппинг в новую систему |
|---|---|---|---|
| POST | /auth/login | Keycloak auth (JWT) | Сохраняем Keycloak, меняем на ConnectRPC interceptor |
| POST | /auth/logout | Logout | Keycloak — без изменений |
| POST | /auth/refresh | Refresh tokens | Keycloak — без изменений |
| POST | /v1/getmyorders | Мои заказы | OMS → ListOrders (ConnectRPC) |
| POST | /v1/getorders | Заказы по категории | OMS → ListOrders с фильтрами |
| GET | /v1/getorder/{id} | Детали заказа | API Facade → агрегация из OMS + Customer + Billing + Inventory |
| GET | /v1/getprices | Список тарифов | Product Service → ListProducts |
| POST | /types/datatypes | Типы заказов | OMS → ListOrderTypes (справочник) |
| POST | /types/fieldtypes | Поля заказов | Убираем (динамические поля → Protobuf-контракты) |
| POST | /types/citytypes | Города + координаты | Customer Core → ListServiceAreas |
| POST | /types/statuses | Статусы заказов | OMS → ListOrderStatuses (enum в Protobuf) |
Ключевые проблемы Legacy
Декомпозиция GOD-объекта book_orders
book_orders → Customer Core Service
| Legacy поле | Тип (Legacy) | Новая сущность | Новое поле | Трансформация |
|---|---|---|---|---|
fio | string | Customer | full_name | as-is (валидация + trim) |
client | string | Customer | type | "физ" → individual, "юр" → legal_entity |
passport | string | Customer | passport_series, passport_number | split по пробелу: "1234 567890" → series=1234, number=567890 |
cbirth | string | Customer | birth_date | parse date string → google.protobuf.Timestamp |
cplace | string | Customer | birth_place | as-is |
inn | string | Customer | inn | as-is (валидация: 10 или 12 цифр) |
ogrn | string | Customer | ogrn | as-is (валидация: 13 цифр) |
org | string | Customer | organization_name | as-is (только для legal_entity) |
short_name | string | Customer | organization_short_name | as-is |
tel | string | Contact | value (type=phone) | нормализация: +7XXXXXXXXXX |
ph_number | string | Contact | value (type=phone, is_primary) | вторичный телефон → отдельный Contact |
fin_email | string | Contact | value (type=email) | lowercase + trim |
town | string | Address | city | маппинг через CityTypes справочник |
street | string | Address | street | as-is |
home | string | Address | building | as-is |
flat | string | Address | apartment | as-is |
flat_range | string | Address | apartment_range | as-is (для многоквартирных подключений) |
coordinate | string | Address | latitude, longitude | parse: "55.123,37.456" → split |
actual_addr | string | Address | full_address (is_service_address=true) | as-is |
yur_addr | string | Address | full_address (type=legal) | as-is (только для legal_entity) |
fiz_addr | string | Address | full_address (type=physical) | as-is |
post_addr | string | Address | full_address (type=postal) | as-is |
sms | string | Contact | notification_channel | "1" → SMS-enabled |
book_orders → Product & Subscription Service
| Legacy поле | Тип (Legacy) | Новая сущность | Новое поле | Трансформация |
|---|---|---|---|---|
tp | string | Subscription → ProductOffering | code | маппинг через price.tp → ProductOffering.code |
iptv | string | ServiceInstance | type=iptv, parameters.base=true | "1" → создать ServiceInstance |
ip_tv_plus | string | ServiceInstance | parameters.package=plus | "1" → доп. пакет |
ip_tv_football | string | ServiceInstance | parameters.package=football | "1" → доп. пакет |
ip_tv_viasat | string | ServiceInstance | parameters.package=viasat | "1" → доп. пакет |
ip_tv_18plus | string | ServiceInstance | parameters.package=adult | "1" → доп. пакет |
ip_tv_match | string | ServiceInstance | parameters.package=match | "1" → доп. пакет |
ip_tv_shant | string | ServiceInstance | parameters.package=shant | "1" → доп. пакет |
ip_tv_premium | string | ServiceInstance | parameters.package=premium | "1" → доп. пакет |
100mb | string | ServiceInstance | parameters.speed_upgrade=100 | "1" → доп. опция скорости |
s_unl | string | ServiceInstance | parameters.static_ip=true | "1" → опция "статический IP" |
n_unl | string | ServiceInstance | parameters.unlimited_nights=true | "1" → опция |
w_ip | string | ServiceInstance | parameters.white_ip=true | "1" → опция "белый IP" |
r_ctl | string | ServiceInstance | parameters.remote_control=true | "1" → опция |
rent_tv | string | ServiceInstance | equipment_rental, device_type=tv | "1" → аренда TV |
rent_port | string | ServiceInstance | equipment_rental, device_type=port | "1" → аренда порта |
rent_stb | string | ServiceInstance | equipment_rental, device_type=stb | "1" → аренда STB |
rent_stb150 | string | ServiceInstance | equipment_rental, device_type=stb, model=150 | "1" → конкретная модель |
rent_stb180 | string | ServiceInstance | equipment_rental, device_type=stb, model=180 | "1" → конкретная модель |
rent_eltexnv721 | string | ServiceInstance | equipment_rental, device_type=ont, model=nv721 | "1" → конкретная модель |
rent_eltexnv7xxwb | string | ServiceInstance | equipment_rental, device_type=ont, model=nv7xxwb | "1" → конкретная модель |
rent_router | string | ServiceInstance | equipment_rental, device_type=router | "1" → аренда роутера |
rent_router100..300 | string | ServiceInstance | equipment_rental, device_type=router, model=* | "1" → конкретная модель |
usl_list | string | Subscription | linked service list | parse comma-separated → массив ServiceInstance |
stb_type | string | ServiceInstance | parameters.stb_model | as-is |
book_orders → Billing & Finance Service
| Legacy поле | Тип (Legacy) | Новая сущность | Новое поле | Трансформация |
|---|---|---|---|---|
pay | double | Payment | amount (type=one_time) | разовый платёж за подключение |
pay_dsc | string | Payment | description | as-is |
connect_cost | double | Transaction | amount (type=charge, desc=connection) | списание за подключение |
monpay | double | PricePlan | base_price | ежемесячный платёж (верификация с Product Catalog) |
monpaydsc | string | PricePlan | description | as-is |
ls | string | Account | legacy_account_number | лицевой счёт Legacy → сохраняем как reference |
ls_pay | double | Account | balance (snapshot) | текущий баланс на момент миграции |
ls_pay_nb | string | Account | balance_note | примечание к балансу |
ls_ais | string | Account | external_ids.ais | ID в Legacy-биллинге (АИС) |
ais_pay | string | Payment | external_ids.ais | привязка к Legacy-платежу |
to1c | string | Invoice | external_ids.1c | привязка к 1С |
payer | string | Account | payer_reference | кто платит (если отличается от клиента) |
pay_usl | string | Transaction | платные услуги | parse → отдельные charge transactions |
pay_usl_descr | string | Transaction | description | описание платных услуг |
rs | string | BankDetails | settlement_account | расчётный счёт (юрлица) |
ks | string | BankDetails | correspondent_account | корр. счёт |
bik | string | BankDetails | bik | БИК банка |
bank | string | BankDetails | bank_name | название банка |
book_orders → Network Inventory
| Legacy поле | Тип (Legacy) | Новая сущность | Новое поле | Трансформация |
|---|---|---|---|---|
node | string | Device | reference (type=switch) | маппинг имени → Device.hostname |
gpon_node | string | Device | reference (type=olt) | маппинг имени → Device.hostname |
gpon_splitter | string | Device | reference (type=splitter) | as-is |
gpon_box_num | string | распределительная коробка | location_ref | as-is |
gpon_parent | string | Device | parent_device_id | upstream устройство |
device | string | CPE (Device) | reference (type=cpe) | серийник/мак → Device |
device_new | int32 | CPE (Device) | is_new | 1 → новое оборудование |
ip | string | LogicalResource | value (type=ipv4) | as-is (валидация формата) |
mac | string | LogicalResource | value (type=mac) | normalize: XX:XX:XX:XX:XX:XX |
vlang | string | LogicalResource | value (type=vlan) | as-is (вероятно typo: vlan) |
nagios | string | — | deprecated | мониторинг → Prometheus/Grafana |
book_orders → Access Control (AAA)
| Legacy поле | Тип (Legacy) | Новая сущность | Новое поле | Трансформация |
|---|---|---|---|---|
login | string | RadiusProfile | username | as-is |
passwd | string | RadiusProfile | radcheck.password | rehash (если plaintext → bcrypt) |
⚠️ Безопасность: Если Legacy хранит пароли в plaintext или MD5 — при миграции обязательно перехешировать. FreeRADIUS поддерживает
NT-Password,Cleartext-Password,SSHA. Рекомендуется перейти наSSHAили MAC-based auth (IPoE).
book_orders → OMS (Order Management)
| Legacy поле | Тип (Legacy) | Новая сущность | Новое поле | Трансформация |
|---|---|---|---|---|
id | int32 | Order | legacy_id + новый UUID | ID Mapping Table |
order_type | string | Order | type (enum) | маппинг строк → Protobuf enum |
order_view | string | Order | category | маппинг строк → enum |
order_name | string | Order | title | as-is |
status | int32 | Order | status (enum) | Status Mapping Table (см. ниже) |
priority | int32 | Order | priority (enum) | 1=low, 2=medium, 3=high, 4=critical |
day | int32 | Order | scheduled_day | epoch day → google.protobuf.Timestamp |
date_time | string | Order | scheduled_at | parse → Timestamp |
do | int32 | Order | assigned | 1 = назначен исполнитель |
do_date | datetime | Order | assigned_at | as-is |
end_date | datetime | Order | completed_at | as-is |
do_time | datetime | Order | work_started_at | начало работ |
end_time | datetime | Order | work_ended_at | завершение работ |
deadline | datetime | Order | deadline | as-is |
author | string | Order | created_by | маппинг username → User ID |
author_ip | string | Order | metadata.created_from_ip | audit field |
add_time | string | Order | created_at | parse → Timestamp |
editor | string | Order | updated_by | маппинг username → User ID |
editor_ip | string | Order | metadata.updated_from_ip | audit field |
edit_time | datetime | Order | updated_at | as-is |
description | string | Order | description | as-is |
notes | string | Order | internal_notes | as-is |
work | string | Order | work_description | описание выполненных работ |
d_works | string | Order | work_items | parse → массив |
who | string | Order | assignee | маппинг → User ID |
why | string | Order | reason | as-is |
isp | string | Order | assignees[] | Исполнитель(и)! Comma-separated → массив User ID |
ctrl | string | Order | control_note | as-is |
num_in | string | Order | document_numbers.incoming | входящий номер документа |
num_out | string | Order | document_numbers.outgoing | исходящий номер |
num_go | string | Order | document_numbers.dispatch | номер рассылки |
num_date | string | Order | document_numbers.date | дата документа |
office | int32 | Order | office_id | ID офиса |
link | string | Order | external_link | ссылка на внешний ресурс |
link_hash | string | Order | external_link_hash | хеш ссылки |
uploads | string | Order | attachments | parse → массив URL |
mark | string | Order | mark | пометка |
private | string | Order | is_private | "1" → true |
sales | string | Order | sales_channel | канал продаж |
isale | string | Order | sale_id | ID продажи |
erent | string | Order | equipment_rental_note | примечание об аренде |
manager | string | Order | account_manager | менеджер клиента |
phone_grp | string | Order | phone_group | телефонная группа |
observ_ol | string | OrderServiceInstance | parameters.surveillance_online=true | ⚠️ Это услуга, а не поле заказа — переносим в Product & Subscription |
observ_ar | string | OrderServiceInstance | parameters.surveillance_archive=true | ⚠️ Это услуга видеонаблюдения (архив) — переносим в Product & Subscription |
fake_individual | string | Order | metadata.fake_individual | флаг |
not_signed | string | Order | metadata.not_signed | флаг "не подписан" |
⚠️ Сущности из UI Legacy, отсутствующие в API (book_orders)
Анализ скриншота Legacy CRM (mybook-sso.g-service.ru) выявил сущности и функции, которые не представлены в old-crm-api.yaml, но существуют в реальном UI и должны быть учтены при миграции:
| Legacy UI элемент | Описание | Куда мигрируем | Приоритет |
|---|---|---|---|
| Потомки (child orders) | Иерархия заказов: родитель → потомки. UI: "Потомки: отсутствуют" | Order.parent_order_id (UUID, nullable) в OMS | 🔴 Высокий |
| Комментарии | Лента комментариев к заказу. UI: "Последние комментарии", "добавить" | Новая сущность OrderComment в OMS (id, order_id, author, text, created_at) | 🔴 Высокий |
| Номер регистрации | Отдельный от ID заказа номер (UI: "Номер регистрации: 7751") | Order.registration_number (string, auto-increment per year) | 🟡 Средний |
| История ремонтов | Ссылка на прошлые ремонты по адресу/абоненту | Query: ListOrders(customer_id, type=REPAIR) — не отдельная сущность, а агрегация | 🟡 Средний |
| Доска объявлений | Правый сайдбар: внутренние объявления для сотрудников | Отдельный микросервис или модуль в BFF (вне скоупа BSS/OSS) | 🟢 Низкий |
| Call-центр | Интеграция с телефонией | Внешняя интеграция через Webhook/API (вне скоупа ядра) | 🟢 Низкий |
| Отправить SMS | Ручная отправка SMS из карточки заказа | Notification Service → SendNotification (ConnectRPC) | 🟡 Средний |
| Выписать счёт | Генерация счёта из карточки заказа | Billing & Finance → GenerateInvoice (ConnectRPC) | 🟡 Средний |
| Печать | Печать карточки заказа | BFF: генерация PDF-представления Order | 🟢 Низкий |
Маппинг Legacy order_type → Новые типы заказов в OMS
На скриншоте видно ~20 категорий заказов в левом сайдбаре. В текущей архитектуре OMS определены только 7 типов. Полный маппинг:
| Legacy order_type | Описание (RU) | Новый OMS тип | Примечание |
|---|---|---|---|
| Подключение | Новое подключение | NEW_CONNECTION | ✅ Есть |
| Бронь | Бронирование порта/ресурсов | RESERVATION | ⚠️ Добавить в OMS |
| Ремонт | Ремонт у абонента | REPAIR | ✅ Есть |
| Gpon ремонт | Ремонт GPON-оборудования | REPAIR (subtype=gpon) | Подтип REPAIR |
| Настройки | Настройка оборудования | CONFIGURATION | ⚠️ Добавить в OMS |
| Домофон | Установка/ремонт домофона | INTERCOM | ⚠️ Добавить или общий SERVICE_REQUEST |
| Оптика | Работы с оптикой | FIBER_WORK | ⚠️ Добавить или общий INFRASTRUCTURE |
| Письма | Входящая/исходящая корреспонденция | CORRESPONDENCE | ⚠️ Добавить — документооборот |
| Остальное | Прочие заявки | OTHER | ⚠️ Добавить — catch-all |
| Услуги | Управление доп. услугами | SERVICE_CHANGE | ⚠️ Добавить в OMS |
| Документы | Документооборот | DOCUMENT | ⚠️ Добавить — юридические документы |
| Развитие | Развитие сети | NETWORK_EXPANSION | ⚠️ Добавить — внутренний тип |
| Должники | Работа с должниками | DUNNING_TASK | ⚠️ Добавить — или обрабатывать через Billing dunning pipeline |
| Инженеры | Инженерные задачи | ENGINEERING_TASK | ⚠️ Добавить — FSM интеграция |
| 3-я линия | Эскалация на 3-ю линию поддержки | ESCALATION_L3 | ⚠️ Добавить |
| Мониторинг | Задачи мониторинга | MONITORING_TASK | ⚠️ Добавить — или отдельная система |
| Склад | Складские операции | WAREHOUSE | ⚠️ Добавить — FSM/склад |
| Обзвон | Массовый обзвон абонентов | OUTBOUND_CALL | ⚠️ Добавить — Call-центр интеграция |
| Чаты | Чат с абонентами | — | Внешняя система (Telegram/WhatsApp интеграция) |
| Карточка | Карточка абонента | — | Это не тип заказа, а view → GetCustomer в Customer Core |
⚠️ Рекомендация: Не переносить все 20 типов как отдельные enum-значения. Лучше ввести двухуровневую классификацию:
order_type(основной тип, ~10 значений) +order_category(подтип, справочник). Это позволит добавлять новые категории без изменения Protobuf-контракта.
price → Product & Subscription Service
| Legacy поле | Тип (Legacy) | Новая сущность | Новое поле | Трансформация |
|---|---|---|---|---|
id | int32 | ProductOffering | legacy_id | ID Mapping Table |
tp | string | ProductOffering | name / code | as-is |
tvalue | int32 | ProductCharacteristic | value (name=download_speed_mbps) | число → характеристика скорости |
cost | double | PricePlan | base_price | as-is |
town | string | ProductOffering | availability_zone | маппинг города → зона |
client | string | ProductOffering | customer_segment | "физ"/"юр" → enum |
type | string | ProductOffering | category | маппинг типа → enum |
tp_rate | string | ProductCharacteristic | description | описание тарифа |
ID Mapping Service
Ключевой компонент миграции — таблица соответствия старых int32 ID и новых UUID.
Схема БД
CREATE TABLE id_mapping (
id BIGSERIAL PRIMARY KEY,
entity_type VARCHAR(50) NOT NULL, -- 'order', 'customer', 'price', etc.
legacy_id INTEGER NOT NULL,
new_id UUID NOT NULL DEFAULT gen_random_uuid(),
migrated_at TIMESTAMPTZ,
verified BOOLEAN DEFAULT FALSE,
UNIQUE (entity_type, legacy_id),
UNIQUE (entity_type, new_id)
);
CREATE INDEX idx_id_mapping_legacy ON id_mapping (entity_type, legacy_id);
CREATE INDEX idx_id_mapping_new ON id_mapping (entity_type, new_id);
-- Примеры:
-- ('order', 12345, '550e8400-e29b-41d4-a716-446655440000', NOW(), true)
-- ('customer', 67890, 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', NOW(), false)
-- ('price', 42, 'b2c3d4e5-f6a7-8901-bcde-f12345678901', NOW(), true)ConnectRPC API
service IDMappingService {
// Получить новый UUID по legacy ID
rpc ResolveNewID(ResolveNewIDRequest) returns (ResolveNewIDResponse);
// Получить legacy ID по новому UUID (для обратной совместимости)
rpc ResolveLegacyID(ResolveLegacyIDRequest) returns (ResolveLegacyIDResponse);
// Массовый маппинг (для batch-миграции)
rpc BulkResolve(BulkResolveRequest) returns (BulkResolveResponse);
}
message ResolveNewIDRequest {
string entity_type = 1;
int32 legacy_id = 2;
}
message ResolveNewIDResponse {
string new_id = 1; // UUID as string
bool migrated = 2;
bool verified = 3;
}Использование в ACL
// internal/customer/acl/legacy_adapter.go
func (a *LegacyAdapter) GetCustomerByLegacyOrderID(ctx context.Context, legacyOrderID int32) (*domain.Customer, error) {
// 1. Resolve legacy order → new order UUID
orderUUID, err := a.idMapping.ResolveNewID(ctx, "order", legacyOrderID)
if err != nil {
return nil, fmt.Errorf("resolve order ID %d: %w", legacyOrderID, err)
}
// 2. Get order → extract customer_id
order, err := a.orderRepo.GetByID(ctx, orderUUID)
if err != nil {
return nil, fmt.Errorf("get order %s: %w", orderUUID, err)
}
// 3. Get customer from Customer Core
return a.customerClient.GetCustomer(ctx, order.CustomerID)
}Legacy DB Adapter (ACL)
Адаптер между Legacy БД и новыми сервисами. Реализует Anti-Corruption Layer — защищает домен от Legacy-моделей.
Архитектура
Пример: конвертация book_orders → Customer
// internal/migration/acl/order_to_customer.go
package acl
type LegacyBookOrder struct {
ID int32 `db:"id"`
FIO string `db:"fio"`
Client string `db:"client"`
Passport string `db:"passport"`
CBirth string `db:"cbirth"`
INN string `db:"inn"`
OGRN string `db:"ogrn"`
Org string `db:"org"`
Tel string `db:"tel"`
FinEmail string `db:"fin_email"`
Town string `db:"town"`
Street string `db:"street"`
Home string `db:"home"`
Flat string `db:"flat"`
Coordinate string `db:"coordinate"`
ActualAddr string `db:"actual_addr"`
// ... остальные поля
}
func (acl *OrderACL) ToCustomer(order LegacyBookOrder) (*customerpb.CreateCustomerRequest, error) {
customerType := customerpb.CustomerType_INDIVIDUAL
if order.Client == "юр" || order.Client == "legal" {
customerType = customerpb.CustomerType_LEGAL_ENTITY
}
// Parse passport: "1234 567890" → series + number
series, number, err := parsePassport(order.Passport)
if err != nil {
return nil, fmt.Errorf("parse passport %q: %w", order.Passport, err)
}
// Parse coordinates: "55.123,37.456" → lat, lon
lat, lon, _ := parseCoordinate(order.Coordinate) // best-effort
// Normalize phone
phone, _ := normalizePhone(order.Tel)
req := &customerpb.CreateCustomerRequest{
FullName: strings.TrimSpace(order.FIO),
Type: customerType,
PassportSeries: series,
PassportNumber: number,
Inn: order.INN,
Ogrn: order.OGRN,
OrganizationName: order.Org,
Contacts: []*customerpb.Contact{},
Addresses: []*customerpb.Address{},
}
if phone != "" {
req.Contacts = append(req.Contacts, &customerpb.Contact{
Type: customerpb.ContactType_PHONE, Value: phone, IsPrimary: true,
})
}
if order.FinEmail != "" {
req.Contacts = append(req.Contacts, &customerpb.Contact{
Type: customerpb.ContactType_EMAIL, Value: strings.ToLower(strings.TrimSpace(order.FinEmail)),
})
}
req.Addresses = append(req.Addresses, &customerpb.Address{
City: order.Town, Street: order.Street, Building: order.Home,
Apartment: order.Flat, FullAddress: order.ActualAddr,
Latitude: lat, Longitude: lon, IsServiceAddress: true,
})
return req, nil
}
func parsePassport(raw string) (series, number string, err error) {
parts := strings.Fields(strings.TrimSpace(raw))
if len(parts) < 2 {
return "", "", fmt.Errorf("expected 'series number', got %q", raw)
}
return parts[0], parts[1], nil
}
func normalizePhone(raw string) (string, error) {
digits := strings.Map(func(r rune) rune {
if r >= '0' && r <= '9' { return r }
return -1
}, raw)
if len(digits) == 11 && digits[0] == '8' {
digits = "7" + digits[1:]
}
if len(digits) != 11 {
return "", fmt.Errorf("invalid phone: %q", raw)
}
return "+" + digits, nil
}Legacy API Proxy (Strangler Fig)
На период миграции старый CRM-фронтенд продолжает работать. Legacy API Proxy проксирует старые эндпоинты на новые сервисы.
Архитектура
Пример: Proxy для getorder
// internal/proxy/handlers/get_order.go
func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
legacyID, _ := strconv.Atoi(chi.URLParam(r, "orderId"))
// Feature flag: использовать новую систему?
if h.features.IsEnabled("new_orders", r.Context()) {
// Resolve legacy ID → new UUID
newID, err := h.idMapping.ResolveNewID(r.Context(), "order", int32(legacyID))
if err != nil {
// Fallback: заказ ещё не мигрирован → читаем из Legacy
h.proxyToLegacy(w, r)
return
}
// Агрегация из новых сервисов
order, _ := h.omsClient.GetOrder(r.Context(), newID)
customer, _ := h.customerClient.GetCustomer(r.Context(), order.CustomerID)
subscription, _ := h.productClient.GetSubscription(r.Context(), order.SubscriptionID)
// Конвертировать обратно в Legacy-формат для старого UI
legacyResponse := h.toLegacyOrderResponse(order, customer, subscription)
json.NewEncoder(w).Encode(legacyResponse)
return
}
h.proxyToLegacy(w, r)
}
// toLegacyOrderResponse — обратная конвертация для совместимости
func (h *OrderHandler) toLegacyOrderResponse(
order *omspb.Order,
customer *customerpb.Customer,
subscription *productpb.Subscription,
) *LegacyGetOrderResponse {
return &LegacyGetOrderResponse{
Error: 0,
Response: LegacyBookOrder{
ID: int32(order.LegacyId),
FIO: customer.FullName,
Client: mapCustomerType(customer.Type),
Passport: customer.PassportSeries + " " + customer.PassportNumber,
Town: extractCity(customer.Addresses),
Street: extractStreet(customer.Addresses),
// ... остальные поля
TP: subscription.ProductOffering.Code,
Status: mapStatusToLegacy(order.Status),
Priority: int32(order.Priority),
},
}
}Status Mapping
Legacy использует int32 для статусов заказов. Нужен маппинг в новые Protobuf enum.
Маппинг таблица
Конкретные значения берутся из
/types/statusesэндпоинта Legacy API. Ниже — типичный маппинг:
| Legacy Status (int) | Legacy Name | New Protobuf Enum | Описание |
|---|---|---|---|
| 0 | Новый | ORDER_STATUS_NEW | Создан, ожидает назначения |
| 1 | В работе | ORDER_STATUS_IN_PROGRESS | Назначен исполнитель |
| 2 | Выполнен | ORDER_STATUS_COMPLETED | Работы завершены |
| 3 | Отменён | ORDER_STATUS_CANCELLED | Отменён клиентом/оператором |
| 4 | На паузе | ORDER_STATUS_ON_HOLD | Ожидание (клиента, оборудования) |
| 5 | Закрыт | ORDER_STATUS_CLOSED | Финализирован, архив |
enum OrderStatus {
ORDER_STATUS_UNSPECIFIED = 0;
ORDER_STATUS_NEW = 1;
ORDER_STATUS_IN_PROGRESS = 2;
ORDER_STATUS_COMPLETED = 3;
ORDER_STATUS_CANCELLED = 4;
ORDER_STATUS_ON_HOLD = 5;
ORDER_STATUS_CLOSED = 6;
}Data Migration Pipeline
ETL-пайплайн для batch-миграции данных из Legacy DB в новые сервисы.
Архитектура
Порядок миграции сущностей
Порядок важен из-за зависимостей между контекстами:
- ProductOffering (из
priceтаблицы) — нет зависимостей - Customer (из
book_orders, дедупликация поfio+passport) — нет зависимостей - Subscription (связь Customer ↔ ProductOffering) — зависит от 1, 2
- Account + Transactions (из
ls,ls_pay,pay) — зависит от 2 - Network Resources (из
node,gpon_*,ip,mac) — зависит от 3 - RADIUS Profiles (из
login,passwd) — зависит от 5 - Orders (из
book_ordersworkflow-полей) — зависит от 2, 3
Дедупликация клиентов
Legacy не имеет отдельной таблицы клиентов — данные клиента размазаны по заказам. При миграции:
// internal/migration/dedup/customer_dedup.go
// Стратегия: группировка по (normalized_fio + passport) → один Customer.
type CustomerKey struct {
NormalizedFIO string
Passport string
}
func DeduplicateCustomers(orders []LegacyBookOrder) map[CustomerKey][]LegacyBookOrder {
groups := make(map[CustomerKey][]LegacyBookOrder)
for _, o := range orders {
key := CustomerKey{
NormalizedFIO: normalizeString(o.FIO),
Passport: normalizeString(o.Passport),
}
groups[key] = append(groups[key], o)
}
return groups
// Для каждой группы: создать одного Customer, привязать все заказы к нему.
}Edge case: Юрлица могут иметь одинаковый ИНН с разными заказами. Для юрлиц ключ дедупликации:
(inn, ogrn). Для физлиц:(fio, passport).
Shadow Mode (Верификация)
На этапе миграции — параллельное чтение из Legacy и New с логированием расхождений.
Что сравниваем
| Поле | Приоритет | Допустимая разница |
|---|---|---|
| Баланс (ls_pay) | Critical | ± 0.01 RUB |
| ФИО | High | case-insensitive + trim |
| Тариф (tp) | High | по code после маппинга |
| Статус | High | после маппинга enum |
| IP/MAC/VLAN | Medium | normalize формат |
| Даты | Medium | ± 1 секунда |
| Описание/notes | Low | ignore whitespace |
Keycloak: что сохраняем
Legacy уже использует Keycloak для авторизации (x-access-token header). План:
- Keycloak остаётся как Identity Provider.
- Заменить
x-access-tokenheader → стандартныйAuthorization: Bearer <token>. - Добавить ConnectRPC interceptor для валидации JWT.
- Добавить RBAC roles в Keycloak:
admin,operator,installer,billing_manager. - Новые сервисы проверяют JWT через Keycloak public key (без обращения к Keycloak на каждый запрос).
// internal/auth/interceptor.go
// ConnectRPC interceptor для JWT-валидации
func NewAuthInterceptor(keycloakPublicKey *rsa.PublicKey) connect.UnaryInterceptorFunc {
return func(next connect.UnaryFunc) connect.UnaryFunc {
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
token := req.Header().Get("Authorization")
claims, err := validateJWT(token, keycloakPublicKey)
if err != nil {
return nil, connect.NewError(connect.CodeUnauthenticated, err)
}
ctx = context.WithValue(ctx, userClaimsKey, claims)
return next(ctx, req)
}
}
}Ссылки по теме
- Целевая архитектура: DDD-модели, ER-диаграммы, карта событий — Доменная логика.
- Стратегия миграции: Фазы, риски, rollback, timeline — Стратегия миграции.
- API: Protobuf-контракты, ConnectRPC, RabbitMQ — API-контракты.
- Принципы: ACL, Strangler Fig, Clean Architecture — Принципы архитектуры.
- Термины: Bounded Context, ACL, CDC, Strangler Fig — Глоссарий.