TECH JOURNAL

Shopee 注文同期の信頼性設計 — Webhook + ポーリングの二重受信で欠損ゼロを目指す

ShareXB!
Shopee 注文同期の信頼性設計 — Webhook + ポーリングの二重受信で欠損ゼロを目指す
目次

はじめに

Shopee の注文同期を構築するとき、最初は「Webhook だけでいいじゃないか」と考えがちだ。しかし本番運用に入ると Webhook の配信遅延・配信失敗・Lambda の一時的な障害などで注文欠損が起きる。

「欠損はあとで手動対応」で済む規模ならよいが、注文数が増えると手動対応は破綻する。ここでは Webhook をメイン、定期ポーリングをバックアップ にした二重受信アーキテクチャを解説する。

アーキテクチャ全体像

【メイン】Shopee Webhook (order.status)
         ↓
      SQS Queue
         ↓
      Lambda: 注文処理

【バックアップ】EventBridge Scheduler(5分ごと)
         ↓
      Lambda: 差分ポーリング
         ↓ (Webhook で処理済みでないものだけ)
      SQS Queue
         ↓
      Lambda: 注文処理(同じ処理ロジック)

どちらの経路から入っても同じ SQS キューを経由し、同じ Lambda で処理することで、処理ロジックを 1 箇所に集約できる。

Webhook の受信と署名検証

Shopee の Webhook には HMAC-SHA256 署名が付く。受信側で必ず検証する。

// app/api/shopee/webhook/route.ts
import crypto from "crypto"
 
export async function POST(request: Request) {
  const body = await request.text()
  const authorization = request.headers.get("authorization") ?? ""
 
  // 署名検証
  const expectedSignature = crypto
    .createHmac("sha256", SHOPEE_PARTNER_KEY)
    .update(`${SHOPEE_PARTNER_ID}|${new URL(request.url).pathname}|${body}`)
    .digest("hex")
 
  if (authorization !== expectedSignature) {
    return new Response("Unauthorized", { status: 401 })
  }
 
  const event = JSON.parse(body)
 
  // 注文イベントのみキューイング
  if (event.code === 15) {
    // code 15: order_status_update
    await enqueueOrderSync(event.data.ordersn)
  }
 
  return new Response("OK", { status: 200 })
}
 
async function enqueueOrderSync(orderSn: string) {
  await sqs.sendMessage({
    QueueUrl: ORDER_QUEUE_URL,
    MessageGroupId: "shopee-orders",
    MessageDeduplicationId: orderSn, // 同一 orderSn は SQS FIFO で重複排除
    MessageBody: JSON.stringify({ orderSn, source: "webhook" }),
  })
}

バックアップポーリング: 差分ポーリング

5 分ごとに getOrderList を叩き、直近 10 分分の注文を取得する。「直近 10 分」としてウィンドウを重複させることで、境界付近の注文欠損を防ぐ。

async function backupPolling(shopId: number, accessToken: string) {
  const timeFrom = Math.floor(Date.now() / 1000) - 10 * 60 // 10 分前
  const timeTo = Math.floor(Date.now() / 1000)
 
  const result = await callShopeeApi(
    "/api/v2/order/get_order_list",
    {
      time_range_field: "create_time",
      time_from: timeFrom,
      time_to: timeTo,
      page_size: 100,
      response_optional_fields: "order_status",
    },
    shopId,
    accessToken
  )
 
  const orders = result.response?.order_list ?? []
 
  for (const order of orders) {
    // Webhook で既に処理済みかチェック
    const processed = await isAlreadyProcessed(order.order_sn)
    if (!processed) {
      await enqueueOrderSync(order.order_sn)
    }
  }
}

処理済みチェック: DynamoDB の TTL を活用

Webhook で処理した注文の order_sn を DynamoDB に記録しておき、バックアップポーリング側で「処理済みか」を確認する。TTL は 30 分に設定しておけば、コストを抑えつつポーリングの重複取得ウィンドウをカバーできる。

async function isAlreadyProcessed(orderSn: string): Promise<boolean> {
  const item = await ddb.get({
    TableName: "shopee-processed-orders",
    Key: { pk: `order:${orderSn}` },
  })
  return !!item.Item
}
 
async function markAsProcessed(orderSn: string) {
  const ttl = Math.floor(Date.now() / 1000) + 30 * 60 // 30分後に自動削除
  await ddb.put({
    TableName: "shopee-processed-orders",
    Item: { pk: `order:${orderSn}`, ttl },
  })
}

注文詳細の取得と処理

SQS から注文 SN を受け取った Lambda で詳細を取得して処理する。

async function processOrder(orderSn: string, shopId: number, accessToken: string) {
  // 注文詳細の取得
  const result = await callShopeeApi(
    "/api/v2/order/get_order_detail",
    {
      order_sn_list: [orderSn],
      response_optional_fields: [
        "buyer_username",
        "item_list",
        "recipient_address",
        "actual_shipping_fee",
      ].join(","),
    },
    shopId,
    accessToken
  )
 
  const order = result.response?.order_list?.[0]
  if (!order) throw new Error(`Order not found: ${orderSn}`)
 
  // WMS へ連携
  await sendToWms(order)
 
  // 処理済み記録
  await markAsProcessed(orderSn)
}

アラート設計: 欠損検知

24 時間ごとに「過去 24 時間の受注数(Shopee API から取得)と OMS の登録数が一致しているか」を比較するバッチを走らせる。差異があればアラートを上げることで、欠損を早期発見できる。

async function dailyOrderAudit() {
  const shopeeCount = await countShopeeOrders(/* 過去24時間 */)
  const omsCount = await countOmsOrders(/* 過去24時間 */)
 
  if (shopeeCount !== omsCount) {
    await sendAlert(`注文欠損検知: Shopee=${shopeeCount}, OMS=${omsCount}`)
  }
}

まとめ

コンポーネント役割
Webhookメイン受信(低レイテンシ)
定期ポーリング(5分)バックアップ(欠損救済)
DynamoDB TTL重複処理の排除(30分)
日次監査バッチ欠損の早期検知

Webhook + ポーリングの二重受信は少し実装コストがかかるが、一度作れば注文欠損に悩む日常運用から解放される。

ShareXB!

この記事を書いた人

渡部 誠也

執行役員 / CTO

独立系 SIer で Web・組み込み・基幹システムの開発を経験し、2017 年に illustrious へ。CTO としてシステム開発事業を立ち上げ、要件定義からコーディングまで一貫して担う。EC に特化した Web アプリケーションを数多く手がける。

ECの業務やシステムについて、
ご相談ください。

いまの運用で困っていること、実現したいことから、一緒に整理します。