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:
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. **Резервные копии** ключа шифрования
|
||||
|
||||
## ✅ ИТОГО
|
||||
|
||||
- Простая схема без лишних полей
|
||||
- Быстрое шифрование с кэшем
|
||||
- Минимальные изменения в коде
|
||||
- Сохранение через существующие инпуты
|
||||
- Защита от просмотра реальных ключей
|
Reference in New Issue
Block a user