docs: добавить планы по API ключам и отладке статистики
- API_KEYS_IMPLEMENTATION_PLAN.md - план реализации системы API ключей - API_KEYS_SECURITY_PLAN.md - план безопасности API ключей - API_KEYS_SIMPLE_PLAN.md - упрощенный план API ключей - DEBUG_SELLER_STATISTICS.md - отладка статистики селлеров - FIX_API_KEYS_SAVING.md - исправление сохранения API ключей 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
516
docs/API_KEYS_IMPLEMENTATION_PLAN.md
Normal file
516
docs/API_KEYS_IMPLEMENTATION_PLAN.md
Normal file
@ -0,0 +1,516 @@
|
|||||||
|
# 🔐 ДЕТАЛЬНЫЙ ПЛАН РЕАЛИЗАЦИИ СИСТЕМЫ API КЛЮЧЕЙ
|
||||||
|
|
||||||
|
## 📋 ТРЕБОВАНИЯ
|
||||||
|
|
||||||
|
1. **Множественные ключи** - организация может иметь несколько API ключей для разных приложений/магазинов
|
||||||
|
2. **Шифрование** - все ключи хранятся зашифрованными
|
||||||
|
3. **Функциональность** - ключи должны работать без задержек на расшифровку
|
||||||
|
4. **Безопасный UI** - никогда не показывать реальные ключи в интерфейсе
|
||||||
|
5. **Аудит** - логирование всех операций с ключами
|
||||||
|
|
||||||
|
## 🏗️ АРХИТЕКТУРА
|
||||||
|
|
||||||
|
### 1. НОВАЯ СХЕМА БД
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
// Основная таблица API ключей
|
||||||
|
model ApiKey {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String // Название ключа (например: "Основной магазин WB")
|
||||||
|
marketplace MarketplaceType
|
||||||
|
encryptedKey String // Зашифрованный ключ
|
||||||
|
encryptionIv String // Вектор инициализации
|
||||||
|
encryptionTag String // Тег аутентификации
|
||||||
|
keyFingerprint String // Последние 4 символа для идентификации
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
isPrimary Boolean @default(false) // Основной ключ для маркетплейса
|
||||||
|
|
||||||
|
// Метаданные
|
||||||
|
clientId String? // Для Ozon
|
||||||
|
validationData Json? // Результаты последней валидации
|
||||||
|
lastValidatedAt DateTime?
|
||||||
|
lastUsedAt DateTime?
|
||||||
|
usageCount Int @default(0)
|
||||||
|
|
||||||
|
// Связи
|
||||||
|
organizationId String
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id])
|
||||||
|
createdById String
|
||||||
|
createdBy User @relation("ApiKeyCreator", fields: [createdById], references: [id])
|
||||||
|
|
||||||
|
// Аудит
|
||||||
|
auditLogs ApiKeyAudit[]
|
||||||
|
|
||||||
|
// Временные метки
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
expiresAt DateTime? // Опциональный срок действия
|
||||||
|
|
||||||
|
@@index([organizationId, marketplace])
|
||||||
|
@@index([keyFingerprint])
|
||||||
|
@@map("api_keys")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Аудит операций с ключами
|
||||||
|
model ApiKeyAudit {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
action ApiKeyAction
|
||||||
|
performedAt DateTime @default(now())
|
||||||
|
performedBy String
|
||||||
|
user User @relation(fields: [performedBy], references: [id])
|
||||||
|
apiKeyId String
|
||||||
|
apiKey ApiKey @relation(fields: [apiKeyId], references: [id])
|
||||||
|
|
||||||
|
// Контекст операции
|
||||||
|
ipAddress String?
|
||||||
|
userAgent String?
|
||||||
|
metadata Json? // Дополнительные данные
|
||||||
|
|
||||||
|
@@index([apiKeyId])
|
||||||
|
@@index([performedBy])
|
||||||
|
@@map("api_key_audit")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ApiKeyAction {
|
||||||
|
CREATED
|
||||||
|
UPDATED
|
||||||
|
VALIDATED
|
||||||
|
ACTIVATED
|
||||||
|
DEACTIVATED
|
||||||
|
DELETED
|
||||||
|
USED_FOR_SYNC
|
||||||
|
VALIDATION_FAILED
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. СЕРВИС ШИФРОВАНИЯ
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/services/encryption-service.ts
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import { LRUCache } from 'lru-cache'
|
||||||
|
|
||||||
|
export class EncryptionService {
|
||||||
|
private algorithm = 'aes-256-gcm'
|
||||||
|
private keyDerivationSalt: Buffer
|
||||||
|
|
||||||
|
// Кэш для расшифрованных ключей (время жизни: 5 минут)
|
||||||
|
private decryptedCache = new LRUCache<string, string>({
|
||||||
|
max: 100,
|
||||||
|
ttl: 1000 * 60 * 5, // 5 минут
|
||||||
|
dispose: (value) => {
|
||||||
|
// Очистка из памяти
|
||||||
|
if (value) {
|
||||||
|
crypto.randomFillSync(Buffer.from(value))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const masterKey = process.env.ENCRYPTION_MASTER_KEY
|
||||||
|
if (!masterKey) {
|
||||||
|
throw new Error('ENCRYPTION_MASTER_KEY not set')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерация ключа шифрования из мастер-ключа
|
||||||
|
this.keyDerivationSalt = Buffer.from(process.env.ENCRYPTION_SALT || 'sfera-api-keys-salt')
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDerivedKey(): Buffer {
|
||||||
|
const masterKey = process.env.ENCRYPTION_MASTER_KEY!
|
||||||
|
return crypto.pbkdf2Sync(masterKey, this.keyDerivationSalt, 10000, 32, 'sha256')
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypt(plainText: string): EncryptedData {
|
||||||
|
const key = this.getDerivedKey()
|
||||||
|
const iv = crypto.randomBytes(16)
|
||||||
|
const cipher = crypto.createCipheriv(this.algorithm, key, iv)
|
||||||
|
|
||||||
|
let encrypted = cipher.update(plainText, 'utf8', 'hex')
|
||||||
|
encrypted += cipher.final('hex')
|
||||||
|
|
||||||
|
return {
|
||||||
|
encrypted,
|
||||||
|
iv: iv.toString('hex'),
|
||||||
|
tag: cipher.getAuthTag().toString('hex'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypt(encryptedData: EncryptedData): string {
|
||||||
|
// Проверяем кэш
|
||||||
|
const cacheKey = `${encryptedData.encrypted}-${encryptedData.iv}`
|
||||||
|
const cached = this.decryptedCache.get(cacheKey)
|
||||||
|
if (cached) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = this.getDerivedKey()
|
||||||
|
const decipher = crypto.createDecipheriv(this.algorithm, key, Buffer.from(encryptedData.iv, 'hex'))
|
||||||
|
|
||||||
|
decipher.setAuthTag(Buffer.from(encryptedData.tag, 'hex'))
|
||||||
|
|
||||||
|
let decrypted = decipher.update(encryptedData.encrypted, 'hex', 'utf8')
|
||||||
|
decrypted += decipher.final('utf8')
|
||||||
|
|
||||||
|
// Сохраняем в кэш
|
||||||
|
this.decryptedCache.set(cacheKey, decrypted)
|
||||||
|
|
||||||
|
return decrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение отпечатка ключа (последние 4 символа)
|
||||||
|
getFingerprint(apiKey: string): string {
|
||||||
|
return apiKey.slice(-4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерация маски для UI
|
||||||
|
generateMask(fingerprint: string): string {
|
||||||
|
return `••••••••••••${fingerprint}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EncryptedData {
|
||||||
|
encrypted: string
|
||||||
|
iv: string
|
||||||
|
tag: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. ОБНОВЛЕННЫЙ СЕРВИС API КЛЮЧЕЙ
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/services/api-key-service.ts
|
||||||
|
export class ApiKeyService {
|
||||||
|
constructor(
|
||||||
|
private prisma: PrismaClient,
|
||||||
|
private encryption: EncryptionService,
|
||||||
|
private marketplace: MarketplaceService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createApiKey(data: CreateApiKeyInput, userId: string): Promise<ApiKey> {
|
||||||
|
// 1. Валидация ключа
|
||||||
|
const validation = await this.marketplace.validateApiKey(data.marketplace, data.apiKey, data.clientId)
|
||||||
|
|
||||||
|
if (!validation.isValid) {
|
||||||
|
throw new Error(`Недействительный API ключ: ${validation.error}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Шифрование
|
||||||
|
const encrypted = this.encryption.encrypt(data.apiKey)
|
||||||
|
const fingerprint = this.encryption.getFingerprint(data.apiKey)
|
||||||
|
|
||||||
|
// 3. Сохранение
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
// Если это первый ключ для маркетплейса - делаем его основным
|
||||||
|
const existingCount = await tx.apiKey.count({
|
||||||
|
where: {
|
||||||
|
organizationId: data.organizationId,
|
||||||
|
marketplace: data.marketplace,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const apiKey = await tx.apiKey.create({
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
marketplace: data.marketplace,
|
||||||
|
encryptedKey: encrypted.encrypted,
|
||||||
|
encryptionIv: encrypted.iv,
|
||||||
|
encryptionTag: encrypted.tag,
|
||||||
|
keyFingerprint: fingerprint,
|
||||||
|
clientId: data.clientId,
|
||||||
|
isPrimary: existingCount === 0,
|
||||||
|
organizationId: data.organizationId,
|
||||||
|
createdById: userId,
|
||||||
|
validationData: validation.data,
|
||||||
|
lastValidatedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4. Аудит
|
||||||
|
await tx.apiKeyAudit.create({
|
||||||
|
data: {
|
||||||
|
action: 'CREATED',
|
||||||
|
apiKeyId: apiKey.id,
|
||||||
|
performedBy: userId,
|
||||||
|
metadata: {
|
||||||
|
name: data.name,
|
||||||
|
marketplace: data.marketplace,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return apiKey
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDecryptedKey(apiKeyId: string, userId: string): Promise<string> {
|
||||||
|
const apiKey = await this.prisma.apiKey.findUnique({
|
||||||
|
where: { id: apiKeyId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error('API ключ не найден')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Аудит использования
|
||||||
|
await this.prisma.apiKeyAudit.create({
|
||||||
|
data: {
|
||||||
|
action: 'USED_FOR_SYNC',
|
||||||
|
apiKeyId: apiKey.id,
|
||||||
|
performedBy: userId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Обновляем статистику
|
||||||
|
await this.prisma.apiKey.update({
|
||||||
|
where: { id: apiKeyId },
|
||||||
|
data: {
|
||||||
|
lastUsedAt: new Date(),
|
||||||
|
usageCount: { increment: 1 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Расшифровка
|
||||||
|
return this.encryption.decrypt({
|
||||||
|
encrypted: apiKey.encryptedKey,
|
||||||
|
iv: apiKey.encryptionIv,
|
||||||
|
tag: apiKey.encryptionTag,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async rotateKey(apiKeyId: string, newApiKey: string, userId: string): Promise<void> {
|
||||||
|
// Валидация нового ключа
|
||||||
|
const apiKey = await this.prisma.apiKey.findUnique({
|
||||||
|
where: { id: apiKeyId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!apiKey) throw new Error('API ключ не найден')
|
||||||
|
|
||||||
|
const validation = await this.marketplace.validateApiKey(apiKey.marketplace, newApiKey, apiKey.clientId)
|
||||||
|
|
||||||
|
if (!validation.isValid) {
|
||||||
|
throw new Error('Новый ключ недействителен')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Шифрование нового ключа
|
||||||
|
const encrypted = this.encryption.encrypt(newApiKey)
|
||||||
|
const fingerprint = this.encryption.getFingerprint(newApiKey)
|
||||||
|
|
||||||
|
// Обновление
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.apiKey.update({
|
||||||
|
where: { id: apiKeyId },
|
||||||
|
data: {
|
||||||
|
encryptedKey: encrypted.encrypted,
|
||||||
|
encryptionIv: encrypted.iv,
|
||||||
|
encryptionTag: encrypted.tag,
|
||||||
|
keyFingerprint: fingerprint,
|
||||||
|
lastValidatedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await tx.apiKeyAudit.create({
|
||||||
|
data: {
|
||||||
|
action: 'UPDATED',
|
||||||
|
apiKeyId: apiKeyId,
|
||||||
|
performedBy: userId,
|
||||||
|
metadata: { reason: 'key_rotation' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Очистка кэша
|
||||||
|
this.encryption.clearCache()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. ОБНОВЛЕННЫЙ UI КОМПОНЕНТ
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/components/settings/ApiKeysTab.tsx
|
||||||
|
export function ApiKeysTab() {
|
||||||
|
const [apiKeys, setApiKeys] = useState<ApiKeyDisplay[]>([])
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
|
const [editingKey, setEditingKey] = useState<string | null>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Заголовок с кнопкой добавления */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h3 className="text-lg font-semibold text-white">API Ключи</h3>
|
||||||
|
<Button onClick={() => setShowAddModal(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Добавить ключ
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Список ключей */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{apiKeys.map((apiKey) => (
|
||||||
|
<Card key={apiKey.id} className="glass-card p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h4 className="font-medium text-white">{apiKey.name}</h4>
|
||||||
|
<Badge variant={apiKey.isPrimary ? 'default' : 'secondary'}>
|
||||||
|
{apiKey.isPrimary ? 'Основной' : 'Дополнительный'}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant={apiKey.isActive ? 'success' : 'destructive'}>
|
||||||
|
{apiKey.isActive ? 'Активен' : 'Неактивен'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<p className="text-sm text-white/60">
|
||||||
|
Маркетплейс: {apiKey.marketplace}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-white/60">
|
||||||
|
Ключ: {apiKey.maskedKey}
|
||||||
|
</p>
|
||||||
|
{apiKey.lastUsedAt && (
|
||||||
|
<p className="text-sm text-white/60">
|
||||||
|
Использован: {formatDate(apiKey.lastUsedAt)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{editingKey === apiKey.id ? (
|
||||||
|
<RotateKeyForm
|
||||||
|
apiKeyId={apiKey.id}
|
||||||
|
onSuccess={() => setEditingKey(null)}
|
||||||
|
onCancel={() => setEditingKey(null)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditingKey(apiKey.id)}
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => toggleKeyStatus(apiKey.id)}
|
||||||
|
>
|
||||||
|
{apiKey.isActive ?
|
||||||
|
<ToggleLeft className="h-4 w-4" /> :
|
||||||
|
<ToggleRight className="h-4 w-4" />
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Модальное окно добавления */}
|
||||||
|
{showAddModal && (
|
||||||
|
<AddApiKeyModal
|
||||||
|
onClose={() => setShowAddModal(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setShowAddModal(false)
|
||||||
|
refetchApiKeys()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. GRAPHQL СХЕМА
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
type ApiKey {
|
||||||
|
id: ID!
|
||||||
|
name: String!
|
||||||
|
marketplace: MarketplaceType!
|
||||||
|
maskedKey: String! # Только маска ••••••••1234
|
||||||
|
isActive: Boolean!
|
||||||
|
isPrimary: Boolean!
|
||||||
|
lastUsedAt: DateTime
|
||||||
|
lastValidatedAt: DateTime
|
||||||
|
createdAt: DateTime!
|
||||||
|
usageCount: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreateApiKeyInput {
|
||||||
|
name: String!
|
||||||
|
marketplace: MarketplaceType!
|
||||||
|
apiKey: String!
|
||||||
|
clientId: String # Для Ozon
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mutation {
|
||||||
|
createApiKey(input: CreateApiKeyInput!): ApiKeyResponse!
|
||||||
|
updateApiKey(id: ID!, newKey: String!): ApiKeyResponse!
|
||||||
|
toggleApiKeyStatus(id: ID!): ApiKeyResponse!
|
||||||
|
deleteApiKey(id: ID!): Boolean!
|
||||||
|
setPrimaryApiKey(id: ID!): ApiKeyResponse!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Query {
|
||||||
|
myApiKeys(marketplace: MarketplaceType): [ApiKey!]!
|
||||||
|
validateApiKey(marketplace: MarketplaceType!, apiKey: String!): ValidationResponse!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 ПОШАГОВЫЙ ПЛАН РЕАЛИЗАЦИИ
|
||||||
|
|
||||||
|
### ЭТАП 1: Подготовка (1 день)
|
||||||
|
|
||||||
|
1. ✅ Создать миграцию БД с новой схемой
|
||||||
|
2. ✅ Настроить переменные окружения для шифрования
|
||||||
|
3. ✅ Создать EncryptionService
|
||||||
|
4. ✅ Написать тесты для шифрования
|
||||||
|
|
||||||
|
### ЭТАП 2: Бэкенд (2-3 дня)
|
||||||
|
|
||||||
|
1. ✅ Создать ApiKeyService
|
||||||
|
2. ✅ Обновить GraphQL схему
|
||||||
|
3. ✅ Создать резолверы для API ключей
|
||||||
|
4. ✅ Добавить middleware для аудита
|
||||||
|
5. ✅ Миграция существующих ключей
|
||||||
|
|
||||||
|
### ЭТАП 3: Интеграция (2 дня)
|
||||||
|
|
||||||
|
1. ✅ Обновить WildberriesService для работы с новой системой
|
||||||
|
2. ✅ Обновить статистику селлеров
|
||||||
|
3. ✅ Добавить автоматическую ротацию ключей
|
||||||
|
4. ✅ Настроить мониторинг использования
|
||||||
|
|
||||||
|
### ЭТАП 4: UI (2 дня)
|
||||||
|
|
||||||
|
1. ✅ Создать компонент ApiKeysTab
|
||||||
|
2. ✅ Добавить модалки для добавления/редактирования
|
||||||
|
3. ✅ Интегрировать в user-settings
|
||||||
|
4. ✅ Добавить уведомления
|
||||||
|
|
||||||
|
### ЭТАП 5: Тестирование (1 день)
|
||||||
|
|
||||||
|
1. ✅ E2E тесты
|
||||||
|
2. ✅ Тесты безопасности
|
||||||
|
3. ✅ Нагрузочное тестирование
|
||||||
|
|
||||||
|
## 🔒 МЕРЫ БЕЗОПАСНОСТИ
|
||||||
|
|
||||||
|
1. **Никогда не логировать расшифрованные ключи**
|
||||||
|
2. **Автоматическая очистка кэша каждые 5 минут**
|
||||||
|
3. **Принудительная ротация ключей каждые 90 дней**
|
||||||
|
4. **Алерты при подозрительной активности**
|
||||||
|
5. **Ограничение количества ключей на организацию**
|
||||||
|
|
||||||
|
## ⚡ ОПТИМИЗАЦИИ
|
||||||
|
|
||||||
|
1. **LRU кэш для расшифрованных ключей** (5 минут TTL)
|
||||||
|
2. **Индексы БД по organizationId и marketplace**
|
||||||
|
3. **Batch загрузка ключей для статистики**
|
||||||
|
4. **Фоновая валидация ключей раз в сутки**
|
192
docs/API_KEYS_SECURITY_PLAN.md
Normal file
192
docs/API_KEYS_SECURITY_PLAN.md
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
# 🔐 ПЛАН БЕЗОПАСНОСТИ API КЛЮЧЕЙ
|
||||||
|
|
||||||
|
## ТЕКУЩИЕ ПРОБЛЕМЫ БЕЗОПАСНОСТИ
|
||||||
|
|
||||||
|
1. **API ключи хранятся В ОТКРЫТОМ ВИДЕ** в БД ❌
|
||||||
|
2. **Нет истории изменений** ключей ❌
|
||||||
|
3. **Нет аудита доступа** к ключам ❌
|
||||||
|
4. **Ключи видны в GraphQL ответах** ❌
|
||||||
|
|
||||||
|
## РЕКОМЕНДУЕМАЯ АРХИТЕКТУРА
|
||||||
|
|
||||||
|
### 1. ШИФРОВАНИЕ КЛЮЧЕЙ
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// services/crypto-service.ts
|
||||||
|
import crypto from 'crypto'
|
||||||
|
|
||||||
|
export class CryptoService {
|
||||||
|
private algorithm = 'aes-256-gcm'
|
||||||
|
private secretKey = process.env.ENCRYPTION_KEY // 32 байта
|
||||||
|
|
||||||
|
encrypt(text: string): { encrypted: string; iv: string; tag: string } {
|
||||||
|
const iv = crypto.randomBytes(16)
|
||||||
|
const cipher = crypto.createCipheriv(this.algorithm, this.secretKey, iv)
|
||||||
|
|
||||||
|
let encrypted = cipher.update(text, 'utf8', 'hex')
|
||||||
|
encrypted += cipher.final('hex')
|
||||||
|
|
||||||
|
const tag = cipher.getAuthTag()
|
||||||
|
|
||||||
|
return {
|
||||||
|
encrypted,
|
||||||
|
iv: iv.toString('hex'),
|
||||||
|
tag: tag.toString('hex'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypt(encrypted: string, iv: string, tag: string): string {
|
||||||
|
const decipher = crypto.createDecipheriv(this.algorithm, this.secretKey, Buffer.from(iv, 'hex'))
|
||||||
|
|
||||||
|
decipher.setAuthTag(Buffer.from(tag, 'hex'))
|
||||||
|
|
||||||
|
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
|
||||||
|
decrypted += decipher.final('utf8')
|
||||||
|
|
||||||
|
return decrypted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. ОБНОВЛЕННАЯ СХЕМА БД
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model ApiKey {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
marketplace MarketplaceType
|
||||||
|
encryptedKey String // Зашифрованный ключ
|
||||||
|
encryptionIv String // Вектор инициализации
|
||||||
|
encryptionTag String // Тег аутентификации
|
||||||
|
keyHash String // Хеш для быстрого сравнения
|
||||||
|
clientId String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
lastUsedAt DateTime?
|
||||||
|
expiresAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
organizationId String
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id])
|
||||||
|
|
||||||
|
// Аудит
|
||||||
|
createdById String
|
||||||
|
createdBy User @relation("ApiKeyCreatedBy", fields: [createdById], references: [id])
|
||||||
|
lastModifiedById String?
|
||||||
|
lastModifiedBy User? @relation("ApiKeyModifiedBy", fields: [lastModifiedById], references: [id])
|
||||||
|
|
||||||
|
// История изменений
|
||||||
|
auditLogs ApiKeyAuditLog[]
|
||||||
|
|
||||||
|
@@unique([organizationId, marketplace])
|
||||||
|
@@index([keyHash])
|
||||||
|
@@map("api_keys")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ApiKeyAuditLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
action ApiKeyAction // CREATED, UPDATED, VALIDATED, USED, DEACTIVATED
|
||||||
|
performedAt DateTime @default(now())
|
||||||
|
performedBy String
|
||||||
|
user User @relation(fields: [performedBy], references: [id])
|
||||||
|
apiKeyId String
|
||||||
|
apiKey ApiKey @relation(fields: [apiKeyId], references: [id])
|
||||||
|
metadata Json? // Дополнительная информация
|
||||||
|
|
||||||
|
@@index([apiKeyId])
|
||||||
|
@@index([performedBy])
|
||||||
|
@@map("api_key_audit_logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ApiKeyAction {
|
||||||
|
CREATED
|
||||||
|
UPDATED
|
||||||
|
VALIDATED
|
||||||
|
USED
|
||||||
|
DEACTIVATED
|
||||||
|
REACTIVATED
|
||||||
|
DELETED
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. БЕЗОПАСНЫЕ РЕЗОЛВЕРЫ
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Сохранение ключа
|
||||||
|
const encrypted = cryptoService.encrypt(apiKey)
|
||||||
|
await prisma.apiKey.create({
|
||||||
|
data: {
|
||||||
|
organizationId: user.organization.id,
|
||||||
|
marketplace,
|
||||||
|
encryptedKey: encrypted.encrypted,
|
||||||
|
encryptionIv: encrypted.iv,
|
||||||
|
encryptionTag: encrypted.tag,
|
||||||
|
keyHash: crypto.createHash('sha256').update(apiKey).digest('hex'),
|
||||||
|
createdById: context.user.id,
|
||||||
|
// Аудит
|
||||||
|
auditLogs: {
|
||||||
|
create: {
|
||||||
|
action: 'CREATED',
|
||||||
|
performedBy: context.user.id,
|
||||||
|
metadata: { ip: context.ip, userAgent: context.userAgent },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Использование ключа
|
||||||
|
const apiKeyRecord = await prisma.apiKey.findUnique({ where: { id } })
|
||||||
|
const decryptedKey = cryptoService.decrypt(
|
||||||
|
apiKeyRecord.encryptedKey,
|
||||||
|
apiKeyRecord.encryptionIv,
|
||||||
|
apiKeyRecord.encryptionTag,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Логирование использования
|
||||||
|
await prisma.apiKeyAuditLog.create({
|
||||||
|
data: {
|
||||||
|
action: 'USED',
|
||||||
|
apiKeyId: apiKeyRecord.id,
|
||||||
|
performedBy: context.user.id,
|
||||||
|
metadata: { purpose: 'statistics_fetch' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. ЗАЩИТА В GRAPHQL
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Никогда не возвращать расшифрованные ключи
|
||||||
|
type ApiKey {
|
||||||
|
id: ID!
|
||||||
|
marketplace: MarketplaceType!
|
||||||
|
isActive: Boolean!
|
||||||
|
lastUsedAt: DateTime
|
||||||
|
createdAt: DateTime!
|
||||||
|
# НЕ ВКЛЮЧАТЬ: apiKey, encryptedKey, encryptionIv, encryptionTag
|
||||||
|
# Показывать только маскированную версию
|
||||||
|
maskedKey: String! # "••••••••••1234"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. ПЕРЕМЕННЫЕ ОКРУЖЕНИЯ
|
||||||
|
|
||||||
|
```env
|
||||||
|
# .env.local
|
||||||
|
ENCRYPTION_KEY=your-32-byte-base64-encoded-key-here # Генерировать: openssl rand -base64 32
|
||||||
|
```
|
||||||
|
|
||||||
|
## ПЛАН МИГРАЦИИ
|
||||||
|
|
||||||
|
1. **Создать CryptoService**
|
||||||
|
2. **Обновить схему БД** с новыми полями
|
||||||
|
3. **Миграция существующих ключей** (зашифровать)
|
||||||
|
4. **Обновить резолверы** для работы с шифрованием
|
||||||
|
5. **Добавить аудит** всех операций
|
||||||
|
6. **Обновить UI** для показа только маскированных ключей
|
||||||
|
|
||||||
|
## ДОПОЛНИТЕЛЬНЫЕ МЕРЫ
|
||||||
|
|
||||||
|
- Ротация ключей шифрования каждые 90 дней
|
||||||
|
- Автоматическое удаление неиспользуемых ключей
|
||||||
|
- Уведомления об аномальной активности
|
||||||
|
- Rate limiting для API операций
|
||||||
|
- Двухфакторная аутентификация для изменения ключей
|
258
docs/API_KEYS_SIMPLE_PLAN.md
Normal file
258
docs/API_KEYS_SIMPLE_PLAN.md
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
# 🔐 УПРОЩЕННЫЙ ПЛАН СИСТЕМЫ API КЛЮЧЕЙ
|
||||||
|
|
||||||
|
## 📋 ТРЕБОВАНИЯ (УТОЧНЕННЫЕ)
|
||||||
|
|
||||||
|
1. **Один ключ на маркетплейс** для организации
|
||||||
|
2. **Шифрование** всех ключей в БД
|
||||||
|
3. **Быстрый доступ** без задержек
|
||||||
|
4. **Безопасный UI** - только звездочки, не копируются
|
||||||
|
5. **Простота** - минимум сложности
|
||||||
|
|
||||||
|
## 🏗️ АРХИТЕКТУРА
|
||||||
|
|
||||||
|
### 1. ПРОСТАЯ СХЕМА БД
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model ApiKey {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
marketplace MarketplaceType // WILDBERRIES или OZON
|
||||||
|
encryptedKey String // Зашифрованный ключ
|
||||||
|
encryptionIv String // Для расшифровки
|
||||||
|
encryptionTag String // Для проверки целостности
|
||||||
|
clientId String? // Только для Ozon
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
|
organizationId String
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id])
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([organizationId, marketplace]) // Один ключ на маркетплейс
|
||||||
|
@@map("api_keys")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. ПРОСТОЙ СЕРВИС ШИФРОВАНИЯ
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/services/crypto.service.ts
|
||||||
|
import crypto from 'crypto'
|
||||||
|
|
||||||
|
export class CryptoService {
|
||||||
|
private algorithm = 'aes-256-gcm'
|
||||||
|
private key: Buffer
|
||||||
|
|
||||||
|
// Простой кэш для производительности
|
||||||
|
private cache = new Map<string, { value: string; expires: number }>()
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Ключ из переменной окружения
|
||||||
|
const masterKey = process.env.ENCRYPTION_KEY
|
||||||
|
if (!masterKey || masterKey.length !== 64) {
|
||||||
|
throw new Error('ENCRYPTION_KEY must be 64 hex characters')
|
||||||
|
}
|
||||||
|
this.key = Buffer.from(masterKey, 'hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypt(text: string): { encrypted: string; iv: string; tag: string } {
|
||||||
|
const iv = crypto.randomBytes(16)
|
||||||
|
const cipher = crypto.createCipheriv(this.algorithm, this.key, iv)
|
||||||
|
|
||||||
|
let encrypted = cipher.update(text, 'utf8', 'hex')
|
||||||
|
encrypted += cipher.final('hex')
|
||||||
|
|
||||||
|
return {
|
||||||
|
encrypted,
|
||||||
|
iv: iv.toString('hex'),
|
||||||
|
tag: cipher.getAuthTag().toString('hex'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypt(encrypted: string, iv: string, tag: string): string {
|
||||||
|
// Проверяем кэш
|
||||||
|
const cacheKey = `${encrypted}-${iv}`
|
||||||
|
const cached = this.cache.get(cacheKey)
|
||||||
|
if (cached && cached.expires > Date.now()) {
|
||||||
|
return cached.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Расшифровываем
|
||||||
|
const decipher = crypto.createDecipheriv(this.algorithm, this.key, Buffer.from(iv, 'hex'))
|
||||||
|
|
||||||
|
decipher.setAuthTag(Buffer.from(tag, 'hex'))
|
||||||
|
|
||||||
|
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
|
||||||
|
decrypted += decipher.final('utf8')
|
||||||
|
|
||||||
|
// Сохраняем в кэш на 5 минут
|
||||||
|
this.cache.set(cacheKey, {
|
||||||
|
value: decrypted,
|
||||||
|
expires: Date.now() + 5 * 60 * 1000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Очищаем старые записи
|
||||||
|
this.cleanCache()
|
||||||
|
|
||||||
|
return decrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanCache() {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [key, item] of this.cache) {
|
||||||
|
if (item.expires < now) {
|
||||||
|
this.cache.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. ОБНОВЛЕНИЕ РЕЗОЛВЕРА
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// В wildberries resolver
|
||||||
|
import { CryptoService } from '../../../services/crypto.service'
|
||||||
|
|
||||||
|
const cryptoService = new CryptoService()
|
||||||
|
|
||||||
|
// При сохранении ключа
|
||||||
|
const encrypted = cryptoService.encrypt(apiKey)
|
||||||
|
|
||||||
|
await prisma.apiKey.upsert({
|
||||||
|
where: {
|
||||||
|
organizationId_marketplace: {
|
||||||
|
organizationId: user.organization.id,
|
||||||
|
marketplace: 'WILDBERRIES',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
encryptedKey: encrypted.encrypted,
|
||||||
|
encryptionIv: encrypted.iv,
|
||||||
|
encryptionTag: encrypted.tag,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
organizationId: user.organization.id,
|
||||||
|
marketplace: 'WILDBERRIES',
|
||||||
|
encryptedKey: encrypted.encrypted,
|
||||||
|
encryptionIv: encrypted.iv,
|
||||||
|
encryptionTag: encrypted.tag,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// При использовании ключа
|
||||||
|
const apiKeyRecord = await prisma.apiKey.findUnique({
|
||||||
|
where: {
|
||||||
|
organizationId_marketplace: {
|
||||||
|
organizationId: user.organization.id,
|
||||||
|
marketplace: 'WILDBERRIES',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!apiKeyRecord || !apiKeyRecord.isActive) {
|
||||||
|
return { success: false, message: 'API ключ не найден' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptedKey = cryptoService.decrypt(
|
||||||
|
apiKeyRecord.encryptedKey,
|
||||||
|
apiKeyRecord.encryptionIv,
|
||||||
|
apiKeyRecord.encryptionTag,
|
||||||
|
)
|
||||||
|
|
||||||
|
const wbService = new WildberriesService(decryptedKey)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. ОБНОВЛЕНИЕ USER-SETTINGS
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// В handleSave добавить после сохранения профиля:
|
||||||
|
|
||||||
|
// Сохраняем API ключи если они изменились
|
||||||
|
if (formData.wildberriesApiKey && formData.wildberriesApiKey !== '••••••••••••••••••••') {
|
||||||
|
await apolloClient.mutate({
|
||||||
|
mutation: ADD_MARKETPLACE_API_KEY,
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
marketplace: 'WILDBERRIES',
|
||||||
|
apiKey: formData.wildberriesApiKey,
|
||||||
|
validateOnly: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.ozonApiKey && formData.ozonApiKey !== '••••••••••••••••••••') {
|
||||||
|
await apolloClient.mutate({
|
||||||
|
mutation: ADD_MARKETPLACE_API_KEY,
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
marketplace: 'OZON',
|
||||||
|
apiKey: formData.ozonApiKey,
|
||||||
|
clientId: formData.ozonClientId,
|
||||||
|
validateOnly: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем данные пользователя
|
||||||
|
await apolloClient.refetchQueries({ include: [GET_ME] })
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. ЗАЩИТА UI
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// В user-settings.tsx инпуты остаются как есть:
|
||||||
|
<Input
|
||||||
|
value={
|
||||||
|
isEditing
|
||||||
|
? formData.wildberriesApiKey || ''
|
||||||
|
: user?.organization?.apiKeys?.find((key) => key.marketplace === 'WILDBERRIES')
|
||||||
|
? '••••••••••••••••••••' // Показываем звездочки
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
onChange={(e) => handleInputChange('wildberriesApiKey', e.target.value)}
|
||||||
|
placeholder="Введите API ключ Wildberries"
|
||||||
|
readOnly={!isEditing}
|
||||||
|
className="glass-input"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 ПРОСТОЙ ПЛАН РЕАЛИЗАЦИИ
|
||||||
|
|
||||||
|
### ШАГ 1: Подготовка (2 часа)
|
||||||
|
|
||||||
|
1. Создать миграцию БД
|
||||||
|
2. Генерировать ключ шифрования: `openssl rand -hex 32`
|
||||||
|
3. Добавить в .env: `ENCRYPTION_KEY=сгенерированный_ключ`
|
||||||
|
|
||||||
|
### ШАГ 2: Бэкенд (4 часа)
|
||||||
|
|
||||||
|
1. Создать CryptoService
|
||||||
|
2. Обновить резолвер addMarketplaceApiKey
|
||||||
|
3. Обновить резолвер getWildberriesStatistics
|
||||||
|
4. Миграция существующих ключей (если есть)
|
||||||
|
|
||||||
|
### ШАГ 3: Фронтенд (2 часа)
|
||||||
|
|
||||||
|
1. Обновить handleSave в user-settings
|
||||||
|
2. Проверить что звездочки не копируются
|
||||||
|
3. Тестирование
|
||||||
|
|
||||||
|
## 🔒 БЕЗОПАСНОСТЬ
|
||||||
|
|
||||||
|
1. **ENCRYPTION_KEY** - хранить в безопасном месте
|
||||||
|
2. **Не логировать** расшифрованные ключи
|
||||||
|
3. **HTTPS обязателен** для передачи ключей
|
||||||
|
4. **Резервные копии** ключа шифрования
|
||||||
|
|
||||||
|
## ✅ ИТОГО
|
||||||
|
|
||||||
|
- Простая схема без лишних полей
|
||||||
|
- Быстрое шифрование с кэшем
|
||||||
|
- Минимальные изменения в коде
|
||||||
|
- Сохранение через существующие инпуты
|
||||||
|
- Защита от просмотра реальных ключей
|
81
docs/DEBUG_SELLER_STATISTICS.md
Normal file
81
docs/DEBUG_SELLER_STATISTICS.md
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
# 🔍 ДИАГНОСТИКА ПРОБЛЕМЫ /seller/statistics
|
||||||
|
|
||||||
|
## 📊 ПРОБЛЕМА
|
||||||
|
|
||||||
|
Страница статистики селлера не отображает данные
|
||||||
|
|
||||||
|
## 🔴 КОРНЕВАЯ ПРИЧИНА
|
||||||
|
|
||||||
|
**Отсутствует API ключ Wildberries в организации**
|
||||||
|
|
||||||
|
### Детали:
|
||||||
|
|
||||||
|
1. GraphQL резолвер `getWildberriesStatistics` проверяет наличие API ключа:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const apiKey = user.organization.apiKeys.find((key) => key.marketplace === 'WILDBERRIES' && key.isActive)
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'API ключ Wildberries не найден',
|
||||||
|
data: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Компонент SalesTab корректно обрабатывает этот случай и показывает mock данные
|
||||||
|
|
||||||
|
## ✅ РЕШЕНИЯ
|
||||||
|
|
||||||
|
### ВАРИАНТ 1: Добавить API ключ в БД
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO "ApiKey" (
|
||||||
|
"id",
|
||||||
|
"apiKey",
|
||||||
|
"marketplace",
|
||||||
|
"isActive",
|
||||||
|
"organizationId",
|
||||||
|
"createdAt"
|
||||||
|
) VALUES (
|
||||||
|
gen_random_uuid(),
|
||||||
|
'YOUR_WILDBERRIES_API_KEY',
|
||||||
|
'WILDBERRIES',
|
||||||
|
true,
|
||||||
|
'SELLER_ORGANIZATION_ID',
|
||||||
|
NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### ВАРИАНТ 2: Использовать mock данные (временное решение)
|
||||||
|
|
||||||
|
Изменить резолвер для возврата mock данных при отсутствии API ключа:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (!apiKey) {
|
||||||
|
// Возвращаем mock данные для разработки
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Using mock data (no API key)',
|
||||||
|
data: generateMockStatistics(dateFrom, dateTo),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ВАРИАНТ 3: Добавить UI для настройки API ключа
|
||||||
|
|
||||||
|
Создать страницу настроек для селлеров где они могут добавить свой API ключ Wildberries
|
||||||
|
|
||||||
|
## 📋 ПРОВЕРКА
|
||||||
|
|
||||||
|
1. Откройте Prisma Studio: `npx prisma studio`
|
||||||
|
2. Проверьте таблицу ApiKey
|
||||||
|
3. Убедитесь что у организации селлера есть активный ключ с marketplace = 'WILDBERRIES'
|
||||||
|
|
||||||
|
## 🎯 СТАТУС
|
||||||
|
|
||||||
|
- Архитектура: ✅ Работает
|
||||||
|
- GraphQL: ✅ Корректно настроен
|
||||||
|
- UI компоненты: ✅ Рендерятся без ошибок
|
||||||
|
- API интеграция: ❌ Требуется API ключ
|
77
docs/FIX_API_KEYS_SAVING.md
Normal file
77
docs/FIX_API_KEYS_SAVING.md
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
# 🔧 ИСПРАВЛЕНИЕ СОХРАНЕНИЯ API КЛЮЧЕЙ
|
||||||
|
|
||||||
|
## ПРОБЛЕМА
|
||||||
|
|
||||||
|
В настройках пользователя (вкладка API) поля для API ключей есть, но они не сохраняются в БД.
|
||||||
|
|
||||||
|
## ПРИЧИНА
|
||||||
|
|
||||||
|
В функции `handleSave` в user-settings.tsx отсутствует логика для сохранения API ключей через мутацию `ADD_MARKETPLACE_API_KEY`.
|
||||||
|
|
||||||
|
## РЕШЕНИЕ
|
||||||
|
|
||||||
|
### 1. Добавить в handleSave после сохранения профиля:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Сохраняем API ключи маркетплейсов
|
||||||
|
if (formData.wildberriesApiKey) {
|
||||||
|
await apolloClient.mutate({
|
||||||
|
mutation: ADD_MARKETPLACE_API_KEY,
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
marketplace: 'WILDBERRIES',
|
||||||
|
apiKey: formData.wildberriesApiKey,
|
||||||
|
validateOnly: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.ozonApiKey) {
|
||||||
|
await apolloClient.mutate({
|
||||||
|
mutation: ADD_MARKETPLACE_API_KEY,
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
marketplace: 'OZON',
|
||||||
|
apiKey: formData.ozonApiKey,
|
||||||
|
clientId: formData.ozonClientId, // если требуется
|
||||||
|
validateOnly: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Импортировать мутацию:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { UPDATE_USER_PROFILE, UPDATE_ORGANIZATION_BY_INN, ADD_MARKETPLACE_API_KEY } from '@/graphql/mutations'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Обновить GET_ME запрос после сохранения:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Обновляем кэш с новыми API ключами
|
||||||
|
await apolloClient.refetchQueries({
|
||||||
|
include: [GET_ME],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## АЛЬТЕРНАТИВНОЕ РЕШЕНИЕ
|
||||||
|
|
||||||
|
Создать отдельную страницу управления API ключами с:
|
||||||
|
|
||||||
|
- Валидацией ключей перед сохранением
|
||||||
|
- Показом статуса интеграции
|
||||||
|
- Возможностью удаления ключей
|
||||||
|
- Тестированием соединения
|
||||||
|
|
||||||
|
## ТЕКУЩИЙ СТАТУС ПОЛЬЗОВАТЕЛЕЙ
|
||||||
|
|
||||||
|
Пользователи МОГУТ добавлять API ключи через:
|
||||||
|
|
||||||
|
1. Регистрацию (если добавлена логика)
|
||||||
|
2. GraphQL мутацию напрямую
|
||||||
|
3. Prisma Studio (администратор)
|
||||||
|
|
||||||
|
НО НЕ МОГУТ через UI настроек!
|
Reference in New Issue
Block a user