Агуулгыг алгасах

Idempotent webhook handler

Нэг event хоёр удаа ирэх, дараалал холилдох, хуучин event хожуу ирэх нь хэвийн (Хүргэлт). Энэ хуудасны загвараар бичсэн handler эдгээр бүгдэд нэг л удаа, зөв ажилладаг: давхар бараа олгохгүй, захиалгыг хоёр удаа хаахгүй.

  • Хугацаа: 30 минут.
  • Хэрэгтэй: гарын үсэг шалгалт (Гарын үсэг), өгөгдлийн сан, background queue (эсвэл ядаж тусдаа goroutine / worker), payments:read scope-той API түлхүүр (GET /payment-intents/{id}-д).

1. Зарчим

  1. Шалга — түүхий body дээр гарын үсэг. Буруу бол 400, юу ч бүү хий.
  2. Давхардлыг арилга — event-ийн id-г (evt_…) unique түлхүүртэй хүснэгтэд бич. Аль хэдийн байвал 2xx буцаагаад зогс.
  3. Queue-д хий, 2xx-ыг хурдан буцаа — 10 секундээс өмнө. Удаан ажил (бараа олгох, имэйл, нөөц) handler дотор биш.
  4. Объектыг дахин унш — worker нь GET /payment-intents/{id}-ээр intent-ийн одоогийн төлөвийг уншина.
  5. Одоогийн төлөвөөр шийд, event-ийн төрөл ба дарааллаар биш: succeeded бол олго, canceled/expired бол захиалгыг хаа. Хуучин event (жишээ нь хожуу ирсэн claimed) одоогийн төлөвийг буцааж өөрчлөхгүй.
  6. Өөрийн үйлдлийг idempotent болго — «захиалга 7-г олгосон» гэдгийг захиалгын мөрөнд бич, олгохоос өмнө шалга (event-ийн dedupe нь нэг event-ийг хамгаална; хоёр өөр event нэг захиалгыг хоёр удаа олгох ёсгүй).
  7. Мэдэхгүй type-д 2xx — шинэ event minor хувилбарт нэмэгдэж болно.
  8. livemode: false event-ээр бодит бараа олгохгүй.

2. Dedupe хүснэгт

CREATE TABLE gerege_webhook_events (
    event_id     TEXT PRIMARY KEY,          -- evt_… (Gerege-Event-Id)
    type         TEXT NOT NULL,
    object_id    TEXT,                      -- pi_…, re_…, st_…
    livemode     BOOLEAN NOT NULL,
    body         JSONB NOT NULL,            -- ирсэн яг тэр body (шалгалт, дахин тоглуулахад)
    received_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    processed_at TIMESTAMPTZ
);

-- Handler: шинэ бол 1 мөр, давхар бол 0 мөр.
INSERT INTO gerege_webhook_events (event_id, type, object_id, livemode, body)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (event_id) DO NOTHING;

Worker ажлаа дуусгаад processed_at-ийг бичнэ; дутуу үлдсэн мөрийг (worker унасан) дахин боловсруулахад ашиглана.

3. Жишээ

import express from "express";
import { GeregeMiniApp, GeregeWebhookError } from "@gerege/miniapp-node";

const gerege = new GeregeMiniApp({
  clientId: process.env.GEREGE_CLIENT_ID!,
  clientSecret: process.env.GEREGE_CLIENT_SECRET!,
});
const secrets = process.env.GEREGE_WEBHOOK_SECRETS!.split(",");
const app = express();

// express.json()-оос ӨМНӨ, зөвхөн энэ замд түүхий body
app.post("/webhooks/gerege", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = gerege.webhooks.verify(req.body, gerege.webhooks.signatureFromHeaders(req.headers), secrets);
  } catch (err) {
    if (err instanceof GeregeWebhookError) return res.sendStatus(400);
    throw err;
  }
  const objectId = (event.data.object as { id?: string }).id ?? null;
  const inserted = await db.query(
    `INSERT INTO gerege_webhook_events (event_id, type, object_id, livemode, body)
     VALUES ($1, $2, $3, $4, $5) ON CONFLICT (event_id) DO NOTHING`,
    [event.id, event.type, objectId, event.livemode, req.body.toString("utf8")],
  );
  if (inserted.rowCount === 1) await queue.add("gerege-event", { eventId: event.id });
  res.sendStatus(204); // давхар ч 2xx
});

// Worker: одоогийн төлөвөөр шийднэ
async function handle({ eventId }: { eventId: string }) {
  const { type, object_id: id } = await loadEvent(eventId);
  if (!type.startsWith("payment_intent.") || !id) return markProcessed(eventId);
  const pi = await gerege.paymentIntents.retrieve(id); // client credential
  const order = await findOrderByReference(pi.reference);
  if (!order || pi.livemode !== order.livemode) return markProcessed(eventId); // test ≠ live
  switch (pi.status) {
    case "succeeded":
      await fulfilOnce(order, pi.id); // захиалгын мөрөнд «олгосон» гэж бичээд л олгоно
      break;
    case "requires_capture":
      // manual: бараа гарвал capture, үгүй бол cancel
      break;
    case "canceled":
    case "expired":
      await closeOrder(order);
      break;
  }
  await markProcessed(eventId);
}
import miniapp "github.com/gerege-systems/app-gerege-nexus/sdk/go"

var (
    secrets = strings.Split(os.Getenv("GEREGE_WEBHOOK_SECRETS"), ",")
    gerege  = miniapp.NewClient(os.Getenv("GEREGE_CLIENT_ID"), os.Getenv("GEREGE_CLIENT_SECRET"))
)

func webhook(w http.ResponseWriter, r *http.Request) {
    ev, body, err := miniapp.VerifyRequest(r, secrets, time.Now())
    if err != nil {
        http.Error(w, "invalid webhook", http.StatusBadRequest)
        return
    }
    var obj struct {
        ID string `json:"id"`
    }
    _ = json.Unmarshal(ev.Data.Object, &obj)
    tag, err := db.Exec(r.Context(), `INSERT INTO gerege_webhook_events (event_id, type, object_id, livemode, body)
        VALUES ($1, $2, NULLIF($3, ''), $4, $5) ON CONFLICT (event_id) DO NOTHING`,
        ev.ID, ev.Type, obj.ID, ev.Livemode, string(body))
    if err != nil {
        http.Error(w, "try again", http.StatusInternalServerError) // Gerege дахин илгээнэ
        return
    }
    if tag.RowsAffected() == 1 {
        jobs <- ev.ID
    }
    w.WriteHeader(http.StatusNoContent)
}

func handle(ctx context.Context, eventID string) error {
    e, err := loadEvent(ctx, eventID)
    if err != nil || !strings.HasPrefix(e.Type, "payment_intent.") {
        return err
    }
    pi, err := gerege.PaymentIntents.Get(ctx, e.ObjectID)
    if err != nil {
        return err // дараа дахин
    }
    switch pi.Status {
    case "succeeded":
        return fulfilOnce(ctx, pi)
    case "canceled", "expired":
        return closeOrder(ctx, pi)
    }
    return nil
}

4. Түгээмэл алдаа

Алдаа Юу болох Зөв нь
express.json() эсвэл framework-ийн parse хийсэн body дээр шалгах Гарын үсэг таарахгүй, бүх event 400 Түүхий byte
Handler дотор бараа олгох, имэйл илгээх 10 с хэтэрч timeout → retry → давхар Queue, хурдан 2xx
Event-ийн type-д л итгэх Хожуу ирсэн event хуучин төлөвийг сэргээнэ GET-ээр одоогийн төлөв
Dedupe-гүй Retry бүрт дахин олгоно event_id unique
Мэдэхгүй type-д 400/500 8 оролдлого, дэмий ачаалал 2xx, үл тоомсорло
livemode шалгахгүй Test төлбөрөөр бодит бараа livemode, intent-ийн livemode
return_url?pi=…-д итгэх (redirect) Хэн ч URL бичиж чадна Webhook эсвэл GET

Алдсан event-ийг нөхөх: Events API — webhook-тэй ижил body, ижил handler-аар (event id-аар dedupe хийсэн тул давхар ирсэн нь алгасагдана). Хоёр endpoint нэг handler руу заасан бол нэг event хоёр удаа ирнэ — мөн id-аар нэг удаа л боловсруулагдана.