# ПРАВИЛА ИНТЕГРАЦИИ С МАРКЕТПЛЕЙСАМИ

## 🎯 ОБЗОР ИНТЕГРАЦИЙ

SFERA поддерживает интеграцию с двумя основными российскими маркетплейсами:

- **WILDBERRIES** - полная интеграция с поставками и статистикой
- **OZON** - базовая интеграция с валидацией API ключей

## 🔑 УПРАВЛЕНИЕ API КЛЮЧАМИ

### Типы поддерживаемых маркетплейсов

```typescript
// Enum из GraphQL schema
enum MarketplaceType {
  WILDBERRIES  // Валберис (полная поддержка)
  OZON         // Озон (базовая поддержка)
}
```

### Структура API ключа

```typescript
type MarketplaceApiKey {
  id: ID!
  organizationId: ID!           // Привязка к организации
  marketplace: MarketplaceType! // Тип маркетплейса
  apiKey: String!              // Сам API ключ
  isActive: Boolean!           // Активен ли ключ
  validationData: JSON         // Данные валидации (seller info)
  createdAt: DateTime!
  updatedAt: DateTime!
}
```

### Добавление API ключа

```graphql
# Мутация добавления/обновления API ключа
mutation AddMarketplaceApiKey($input: MarketplaceApiKeyInput!) {
  addMarketplaceApiKey(input: $input) {
    success
    message
    apiKey {
      id
      marketplace
      apiKey
      isActive
      validationData
    }
  }
}

# Input для добавления ключа
input MarketplaceApiKeyInput {
  marketplace: MarketplaceType!
  apiKey: String!
  clientId: String # Требуется только для Ozon
  validateOnly: Boolean # Только валидация без сохранения
}
```

## 🔍 ВАЛИДАЦИЯ API КЛЮЧЕЙ

### Служба валидации (MarketplaceService)

```typescript
// Реальная реализация из services/marketplace-service.ts
export class MarketplaceService {
  private wbApiUrl = 'https://common-api.wildberries.ru'
  private ozonApiUrl = 'https://api-seller.ozon.ru'

  /**
   * Универсальный метод валидации
   */
  async validateApiKey(
    marketplace: 'WILDBERRIES' | 'OZON',
    apiKey: string,
    clientId?: string,
  ): Promise<MarketplaceValidationResult> {
    switch (marketplace) {
      case 'WILDBERRIES':
        return this.validateWildberriesApiKey(apiKey)
      case 'OZON':
        return this.validateOzonApiKey(apiKey, clientId)
    }
  }
}
```

### Валидация Wildberries

```typescript
// Реальный алгоритм валидации WB API ключа
async validateWildberriesApiKey(apiKey: string): Promise<MarketplaceValidationResult> {
  try {
    // 1. Быстрая проверка через ping endpoint
    const pingResponse = await axios.get(`${this.wbApiUrl}/ping`, {
      headers: { Authorization: `Bearer ${apiKey}` },
      timeout: 5000
    })

    if (pingResponse.status !== 200 || pingResponse.data?.Status !== 'OK') {
      return { isValid: false, message: 'API ключ Wildberries невалиден' }
    }

    // 2. Получение информации о продавце
    const sellerResponse = await axios.get(`${this.wbApiUrl}/api/v1/seller-info`, {
      headers: {
        Authorization: `Bearer ${apiKey}`,
        'Content-Type': 'application/json'
      },
      timeout: 10000
    })

    if (sellerResponse.status === 200 && sellerResponse.data) {
      const sellerData = sellerResponse.data

      return {
        isValid: true,
        message: 'API ключ Wildberries валиден',
        data: {
          sellerId: sellerData.sid,        // Уникальный ID продавца
          sellerName: sellerData.name,     // Наименование продавца
          tradeMark: sellerData.tradeMark  // Торговое наименование
        }
      }
    }

    return {
      isValid: false,
      message: 'Не удалось получить информацию о продавце Wildberries'
    }
  } catch (error) {
    // Обработка различных типов ошибок
    if (error.response?.status === 401) {
      return { isValid: false, message: 'Неверный API ключ Wildberries' }
    }

    if (error.response?.status === 403) {
      return { isValid: false, message: 'Доступ запрещён. Проверьте права API ключа' }
    }

    if (error.response?.status === 429) {
      return { isValid: false, message: 'Слишком много запросов. Попробуйте позже' }
    }

    return { isValid: false, message: 'Ошибка при проверке API ключа Wildberries' }
  }
}
```

### Валидация Ozon

```typescript
// Алгоритм валидации Ozon API ключа (требует Client-Id)
async validateOzonApiKey(apiKey: string, clientId?: string): Promise<MarketplaceValidationResult> {
  try {
    // Client-Id обязателен для Ozon
    if (!clientId) {
      return { isValid: false, message: 'Для Ozon API требуется Client-Id' }
    }

    // Запрос информации о продавце Ozon
    const response = await axios.post(
      `${this.ozonApiUrl}/v1/seller/info`,
      {}, // Пустое тело запроса
      {
        headers: {
          'Api-Key': apiKey,           // Ozon использует заголовок Api-Key
          'Client-Id': clientId,       // Обязательный Client-Id
          'Content-Type': 'application/json'
        },
        timeout: 10000
      }
    )

    if (response.status === 200 && response.data?.result) {
      const sellerData = response.data.result

      return {
        isValid: true,
        message: 'API ключ Ozon валиден',
        data: {
          sellerId: sellerData.id?.toString(),
          sellerName: sellerData.name,
          status: sellerData.status
        }
      }
    }

    return {
      isValid: false,
      message: 'Не удалось получить информацию о продавце Ozon'
    }
  } catch (error) {
    if (error.response?.status === 401) {
      return { isValid: false, message: 'Неверный API ключ или Client-Id для Ozon' }
    }

    return { isValid: false, message: 'Ошибка при проверке API ключа Ozon' }
  }
}
```

### Валидация формата ключа

```typescript
// Предварительная валидация формата перед API запросом
validateApiKeyFormat(marketplace: 'WILDBERRIES' | 'OZON', apiKey: string): boolean {
  if (!apiKey || typeof apiKey !== 'string') {
    return false
  }

  switch (marketplace) {
    case 'WILDBERRIES':
      // WB API ключи (JWT токены): буквы, цифры, дефисы, подчёркивания, точки
      return /^[a-zA-Z0-9\-_.]{10,}$/.test(apiKey)
    case 'OZON':
      // Ozon API ключи: буквы, цифры, дефисы, подчёркивания
      return /^[a-zA-Z0-9\-_]{10,}$/.test(apiKey)
    default:
      return false
  }
}
```

## 🔒 ПРАВИЛА БЕЗОПАСНОСТИ API КЛЮЧЕЙ

### Хранение в БД

```typescript
// Модель ApiKey в Prisma schema
model ApiKey {
  id             String           @id @default(cuid())
  organizationId String           // Привязка к организации
  marketplace    MarketplaceType  // WILDBERRIES | OZON
  apiKey         String           // Храним в открытом виде (организации владеют ключами)
  isActive       Boolean          @default(true)
  validationData Json?            // Закешированные данные продавца
  createdAt      DateTime         @default(now())
  updatedAt      DateTime         @updatedAt

  organization   Organization     @relation(fields: [organizationId], references: [id])

  // Уникальная связка: одна организация = один ключ на маркетплейс
  @@unique([organizationId, marketplace])
}
```

### Ограничения доступа

```typescript
// Из реального кода: только селлеры могут добавлять API ключи маркетплейсов
const user = await prisma.user.findUnique({
  where: { id: context.user.id },
  include: { organization: true },
})

// ПРАВИЛО: API ключи маркетплейсов только для SELLER организаций
if (user.organization.type !== 'SELLER') {
  throw new GraphQLError('API ключи маркетплейсов доступны только для селлеров')
}
```

### Валидация без сохранения

```typescript
// Режим validateOnly: проверка ключа без сохранения в БД
if (validateOnly) {
  return {
    success: true,
    message: 'API ключ действителен',
    apiKey: {
      id: 'validate-only',
      marketplace,
      apiKey: '***', // Скрываем реальный ключ
      isActive: true,
      validationData: validationResult,
    },
  }
}
```

## 📦 WILDBERRIES ИНТЕГРАЦИЯ (РАСШИРЕННАЯ)

### Специализированные поставки WB

```typescript
// Enum статусов поставок Wildberries
enum WildberriesSupplyStatus {
  DRAFT        // Черновик
  CREATED      // Создана в системе WB
  IN_PROGRESS  // В процессе обработки
  DELIVERED    // Доставлена на склад WB
  CANCELLED    // Отменена
}
```

### Карточки товаров WB

```typescript
type WildberriesSupplyCard {
  id: ID!
  nmId: String!           // Артикул товара WB (Номенклатура)
  price: Float!           // Цена товара
  discountedPrice: Float  // Цена со скидкой
  quantity: Int!          // Количество
  selectedServices: [ID!] // Выбранные услуги фулфилмента
}
```

### Создание поставки WB

```graphql
# Мутация создания поставки для Wildberries
mutation CreateWildberriesSupply($input: CreateWildberriesSupplyInput!) {
  createWildberriesSupply(input: $input) {
    success
    message
    supply {
      id
      status
      totalAmount
      totalItems
      cards {
        id
        nmId
        price
        quantity
      }
      organization {
        name
      }
    }
  }
}

# Input для создания поставки WB
input CreateWildberriesSupplyInput {
  deliveryDate: DateTime
  cards: [WildberriesSupplyCardInput!]!
}
```

### Реализация создания поставки WB

```typescript
// Из resolvers.ts: специализированная мутация для WB
createWildberriesSupply: async (_: unknown, args: { input: CreateWildberriesSupplyInput }, context: Context) => {
  if (!context.user) {
    throw new GraphQLError('Требуется авторизация')
  }

  const currentUser = await prisma.user.findUnique({
    where: { id: context.user.id },
    include: { organization: true },
  })

  // ПРОВЕРКА ТИПА: только селлеры могут создавать поставки WB
  if (currentUser.organization.type !== 'SELLER') {
    throw new GraphQLError('Поставки Wildberries доступны только для селлеров')
  }

  try {
    // БИЗНЕС-ЛОГИКА: создание специализированной поставки для WB
    const supplyData = {
      organizationId: currentUser.organization.id,
      type: 'WILDBERRIES_SUPPLY',
      status: 'PENDING',
      cards: args.input.cards.map((card) => ({
        price: card.price,
        discountedPrice: card.discountedPrice,
        selectedQuantity: card.selectedQuantity,
        selectedServices: card.selectedServices || [],
      })),
      createdAt: new Date(),
    }

    // Интеграция с API Wildberries для создания поставки...
    // (детали API интеграции зависят от доступных эндпоинтов WB)

    return {
      success: true,
      message: 'Поставка Wildberries успешно создана',
      supply: supplyData,
    }
  } catch (error) {
    console.error('Ошибка создания поставки WB:', error)
    return {
      success: false,
      message: 'Ошибка при создании поставки Wildberries',
    }
  }
}
```

## 📊 КЕШИРОВАНИЕ И ОПТИМИЗАЦИЯ

### Кеш данных продавца

```typescript
// validationData JSON поле содержит закешированную информацию о продавце
{
  "sellerId": "12345",
  "sellerName": "ООО Рога и копыта",
  "tradeMark": "HORNS&HOOVES",
  "validatedAt": "2025-08-21T10:30:00Z",
  "apiEndpoints": ["seller-info", "stocks", "orders"]  // Доступные эндпоинты
}
```

### TTL для валидации

```typescript
// Переодическая переваlidация API ключей (пример логики)
const shouldRevalidate = (validationData: any): boolean => {
  if (!validationData?.validatedAt) return true

  const lastValidation = new Date(validationData.validatedAt)
  const hoursSinceValidation = (Date.now() - lastValidation.getTime()) / (1000 * 60 * 60)

  return hoursSinceValidation > 24 // Переваlidация раз в сутки
}
```

## 🚨 ОБРАБОТКА ОШИБОК И ЛИМИТОВ

### Rate Limiting

```typescript
// Обработка лимитов API маркетплейсов
if (error.response?.status === 429) {
  return {
    isValid: false,
    message: 'Слишком много запросов к Wildberries API. Попробуйте позже',
  }
}

// Timeout для медленных API
const response = await axios.get(endpoint, {
  timeout: 10000, // 10 секунд максимум
})
```

### Failover стратегии

```typescript
// Graceful fallback при недоступности API маркетплейса
try {
  const result = await marketplaceService.validateApiKey(marketplace, apiKey)
  return result
} catch (error) {
  // Fallback: разрешаем операции с пометкой "не проверено"
  if (allowFallback) {
    return {
      success: true,
      message: 'API ключ сохранен без проверки (сервис временно недоступен)',
      apiKey: { ...keyData, isActive: false }, // Деактивированный ключ до проверки
    }
  }
  throw error
}
```

## 🔄 ЖИЗНЕННЫЙ ЦИКЛ API КЛЮЧА

### Этапы управления ключом

```mermaid
graph TD
    A[Ввод API ключа] --> B[Валидация формата]
    B --> C[API проверка]
    C --> D{Ключ валиден?}
    D -->|Да| E[Сохранение в БД]
    D -->|Нет| F[Ошибка валидации]
    E --> G[Периодическая ревалидация]
    G --> H{Ключ еще действителен?}
    H -->|Да| I[Остается активным]
    H -->|Нет| J[Деактивация ключа]
    J --> K[Уведомление пользователя]
```

### Автоматическое обновление

```typescript
// Фоновая задача проверки API ключей
const validateAllActiveKeys = async () => {
  const activeKeys = await prisma.apiKey.findMany({
    where: { isActive: true },
    include: { organization: true },
  })

  for (const key of activeKeys) {
    const result = await marketplaceService.validateApiKey(key.marketplace, key.apiKey)

    if (!result.isValid) {
      // Деактивируем невалидные ключи
      await prisma.apiKey.update({
        where: { id: key.id },
        data: {
          isActive: false,
          validationData: { ...key.validationData, error: result.message },
        },
      })

      // Уведомляем организацию о проблеме с API ключом
      await notifyOrganization(key.organizationId, {
        type: 'API_KEY_INVALID',
        marketplace: key.marketplace,
        message: result.message,
      })
    }
  }
}
```

## 📈 МЕТРИКИ И МОНИТОРИНГ

### Отслеживаемые метрики

- **Успешность валидации**: процент успешно проваlidированных ключей
- **Время ответа API**: latency интеграций с маркетплейсами
- **Частота ошибок**: rate limiting, timeout, auth errors
- **Активные интеграции**: количество активных API ключей по маркетплейсам

### Логирование

```typescript
// Структурированное логирование интеграций
console.warn('🔍 Marketplace API Operation', {
  operation: 'validate_api_key',
  marketplace: 'WILDBERRIES',
  organizationId: user.organization.id,
  keyLength: apiKey.length,
  keyPreview: apiKey.substring(0, 20) + '...',
  success: result.isValid,
  responseTime: Date.now() - startTime,
  error: result.isValid ? null : result.message,
})
```

---

_Основано на реальном коде: src/services/marketplace-service.ts, src/graphql/resolvers.ts:3257+_  
_Обновлено: 2025-08-21_
