Абстракция интеграций
Интерфейс платёжного шлюза
typescript
interface IPaymentGateway {
createPaymentIntent(params: CreatePaymentParams): Promise<PaymentIntent>;
confirmPayment(intentId: string): Promise<PaymentResult>;
createPayout(params: CreatePayoutParams): Promise<PayoutResult>;
handleWebhook(payload: Buffer, signature: string): Promise<WebhookResult>;
verifyWebhookSignature(payload: Buffer, signature: string): boolean;
}
// Реализации:
// - StripeGateway (международный)
// - YooKassaGateway (Россия/СНГ)Безопасность Webhook (критическая)
Платёжные webhooks должны быть верифицированы для предотвращения:
- Поддельных событий, вызывающих мошенническое выполнение заказов
- Атак повторного воспроизведения, повторно обрабатывающих старые события
- Изменённых данных, меняющих суммы или статусы
Верификация подписи (отраслевой стандарт: HMAC-SHA256)
typescript
import { createHmac, timingSafeEqual } from 'crypto';
export class WebhookVerifier {
constructor(private readonly secret: string) {}
/**
* Верификация подписи webhook с использованием HMAC-SHA256
* Используется: Stripe, GitHub, Shopify, Slack, YooKassa
*/
verify(payload: Buffer, signature: string, timestamp?: number): boolean {
// 1. Проверка временной метки для предотвращения атак повторного воспроизведения (допуск 5 мин)
if (timestamp) {
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) {
throw new Error('Временная метка webhook устарела (возможна атака повторного воспроизведения)');
}
}
// 2. Вычисление ожидаемой подписи
const signedPayload = timestamp
? `${timestamp}.${payload.toString()}`
: payload.toString();
const expectedSignature = createHmac('sha256', this.secret)
.update(signedPayload)
.digest('hex');
// 3. Использование сравнения с защитой от тайминг-атак
const signatureBuffer = Buffer.from(signature);
const expectedBuffer = Buffer.from(expectedSignature);
if (signatureBuffer.length !== expectedBuffer.length) {
return false;
}
return timingSafeEqual(signatureBuffer, expectedBuffer);
}
}Пример обработчика Stripe Webhook
typescript
@Controller('webhooks')
export class StripeWebhookController {
constructor(
private readonly paymentService: PaymentService,
private readonly config: ConfigService,
) {}
@Post('stripe')
@HttpCode(200)
async handleStripeWebhook(
@Req() req: RawBodyRequest<Request>,
@Headers('stripe-signature') signature: string,
): Promise<{ received: boolean }> {
// КРИТИЧНО: Используйте сырое тело, НЕ распарсенный JSON
const rawBody = req.rawBody;
if (!rawBody) {
throw new BadRequestException('Отсутствует сырое тело запроса');
}
// Верификация подписи с помощью Stripe SDK
const webhookSecret = this.config.get('STRIPE_WEBHOOK_SECRET');
let event: Stripe.Event;
try {
event = this.stripe.webhooks.constructEvent(
rawBody,
signature,
webhookSecret,
);
} catch (err) {
throw new BadRequestException(`Ошибка верификации подписи webhook`);
}
// Обработка события с проверкой идемпотентности
await this.paymentService.processWebhookEvent(event);
return { received: true };
}
}Обработка событий Webhook
typescript
async processWebhookEvent(event: PaymentWebhookEvent): Promise<void> {
// 1. Проверка идемпотентности - предотвращение повторной обработки
const idempotencyKey = `webhook:${event.id}`;
const existing = await this.cache.get(idempotencyKey);
if (existing) {
this.logger.info(`Webhook ${event.id} уже обработан, пропуск`);
return;
}
// 2. Обработка в зависимости от типа события
switch (event.type) {
case 'payment_intent.succeeded':
await this.handlePaymentSuccess(event.data);
break;
case 'payment_intent.payment_failed':
await this.handlePaymentFailure(event.data);
break;
case 'charge.refunded':
await this.handleRefund(event.data);
break;
case 'charge.dispute.created':
await this.handleDispute(event.data);
break;
}
// 3. Отметка как обработанного (TTL: 7 дней для отложенных повторов)
await this.cache.set(idempotencyKey, 'processed', 7 * 24 * 60 * 60);
}Чеклист безопасности Webhook
| Проверка | Реализация |
|---|---|
| Верификация подписи | HMAC-SHA256 со сравнением, защищённым от тайминг-атак |
| Предотвращение повторов | Валидация временной метки (допуск 5 мин) |
| Идемпотентность | Дедупликация по ID события с TTL 7 дней |
| Использование сырого тела | Никогда не использовать распарсенный/сериализованный JSON |
| Только HTTPS | Отклонять HTTP webhook-эндпоинты |
| Белый список IP | Опционально: Ограничение до диапазонов IP провайдера |
Интерфейс сервиса уведомлений
typescript
interface INotificationSender {
sendEmail(params: EmailParams): Promise<SendResult>;
sendSMS(params: SMSParams): Promise<SendResult>;
sendPush(params: PushParams): Promise<SendResult>;
}
// Реализации:
// - SendGridEmailSender
// - TwilioSMSSender
// - FirebasePushSenderИнтерфейс сервиса доставки
typescript
interface IDeliveryProvider {
getQuotes(params: QuoteParams): Promise<ShippingOption[]>;
createShipment(params: ShipmentParams): Promise<Shipment>;
trackShipment(trackingNumber: string): Promise<TrackingInfo>;
}
// Реализации:
// - CDEKProvider (Россия)
// - DHLProvider (международный)Резервирование товаров (электронная коммерция)
Проблема
При оформлении заказа товар может быть перепродан, если несколько пользователей пытаются купить один и тот же товар одновременно.
Решение: Мягкое резервирование с таймаутом
sql
CREATE TABLE product.inventory_reservations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
product_id UUID NOT NULL REFERENCES product.products(id),
cart_id UUID REFERENCES product.carts(id),
order_id UUID REFERENCES product.orders(id),
quantity INT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'RESERVED',
-- RESERVED, COMMITTED, RELEASED, EXPIRED
reserved_at TIMESTAMP NOT NULL DEFAULT NOW(),
expires_at TIMESTAMP NOT NULL, -- например, 15 минут от момента резервирования
committed_at TIMESTAMP,
CONSTRAINT positive_quantity CHECK (quantity > 0)
);
CREATE INDEX idx_reservations_product ON product.inventory_reservations(product_id, status);
CREATE INDEX idx_reservations_expires ON product.inventory_reservations(expires_at) WHERE status = 'RESERVED';Поток резервирования
Пользователь добавляет в корзину
│
▼
┌──────────────────┐
│ Проверка доступ- │ available = stock_quantity - SUM(reserved where RESERVED)
│ ного количества │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Создание мягкого │ status=RESERVED, expires_at=NOW()+15min
│ резервирования │
└────────┬─────────┘
│
┌────┴────┐
│ │
▼ ▼
Оформление Таймаут
│ │
▼ ▼
COMMITTED EXPIRED (задание очистки pg-boss)Задание очистки (pg-boss)
typescript
// Запускается каждую минуту для освобождения истёкших резервирований
boss.schedule('release-expired-reservations', '* * * * *', {}, {
retryLimit: 3
});
boss.work('release-expired-reservations', async () => {
await db.inventoryReservation.updateMany({
where: { status: 'RESERVED', expiresAt: { lt: new Date() }},
data: { status: 'EXPIRED' }
});
});