Skip to content

Абстракция интеграций

Интерфейс платёжного шлюза

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' }
  });
});

Связанные документы