Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

durationSec上限300秒を入力時に見ておらず、合計時間上限を超えても保存後の配信時に恒久400エラー

公開 読了時間 約5分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

申込フォームの入力時バリデーションには合計配信時間の上限チェックが無く、v2配信側のdurationSec上限(300秒)とだけ食い違っていたため、申込・契約・入金まで成立した注文が配信処理でだけ恒久的な400エラーになっていた。

結論から

申込フォーム側のバリデーションは「枠の残り数」しか見ていなかった。units(申し込む枠数)がremainingUnits(残り枠)を超えていないかは検証していたが、units × slot_seconds(1注文が占有する合計配信秒数)が、配信先であるv2 Partner APIの上限と一致しているかは、どこも検証していなかった。

結果、v2側がzodスキーマで強制しているdurationSecの上限(300秒)を超える注文でも、申込フォームは普通に受理し、契約も入金も正常に完了する。問題が露見するのは、その注文を実際にv2へ配信しようとした瞬間だけだった。

症状

1注文の合計配信秒数に上限が無いまま、申込→契約→入金のレールを通過した注文が存在した。これらは配信処理に渡された時点で初めて失敗する。

修正前のbookLoopSlotCoreは、枠の残り数チェックの直後に別のチェック(カード決済専用枠の判定)へ進んでおり、合計秒数を見るコードはどこにも存在しなかった。

// src/lib/booking.ts(修正前)
if ((!loop.canIndividual && !privileged) || units > loop.remainingUnits)
  return {
    ok: false,
    error:
      loop.remainingUnits <= 0
        ? "申し訳ありません。この枠は満枠です。別の枠をお選びください。"
        : `現在の空きは残り ${loop.remainingUnits} 枠です。枠数を減らしてお申し込みください。`,
  };
// カード決済専用の枠(進路前プラン・0068): Stripe(integrations.is_enabled)が OFF の間は
// fail-closed(PR-R2)。ON になると解禁され、契約は payment_method='card' で作られる(0073統合)。
if (loop.card_only && !(await isStripeEnabled()))

枠数の上限チェックから、カード決済枠の判定へ直接進んでいる。合計秒数のチェックは存在しない。

原因

原因は、「1注文が使える配信時間の上限」という同じ値が、申込側(kimiteras-portal)と配信側(キミテラス-v2のPartner API)という別々のシステムに、別々の前提で実装されていたことにある。

v2側はdelivery-payload.tsのzodスキーマで、durationSecを1〜300の範囲に強制している。

const DURATION_MIN = 1; // ck_ads_duration_positive (> 0)
const DURATION_MAX = 300;
durationSec: z.number().int().min(DURATION_MIN).max(DURATION_MAX),

このモジュールのコメントには、この検証の性質がそのまま書かれている。

/**
 * ## 【要件2】このモジュールの失敗 = 恒久(4xx)
 * payload 形不正・enum 外・UUID 不正・ads 0 件は「再送で直らない」ので route は **400/422**。
 * DB 一時エラー・asset 取得失敗は route 側の別経路で **5xx**(再送で回復しうる)。
 */

つまりv2側の設計では、durationSecが300を超えるペイロードは仕様として再送で直らない恒久エラー(400/422)として扱われる。ここまでは正しい設計だ。問題は、この「300秒」という上限値を、申込を受け付ける側が一度も見ていなかったことにある。

申込フォームのunits × loop.slot_secondsは、v2へ送るdurationSecの実質的な元データになる。だがbookLoopSlotCoreはそれを計算も検証もしないまま、枠の在庫チェックだけで申込を確定させていた。既定の枠(定員10×30秒)ではちょうど300秒に収まるため、通常の使い方では問題が起きない。枠数を増やして合計が300秒を超える注文だけが、契約・入金まで進んだ後に初めてv2側の恒久エラーに引っかかっていた。

直す

bookLoopSlotCoreに、枠の在庫チェックの直後、カード決済枠の判定の前に、合計秒数のチェックを追加した。

// src/lib/booking.ts(修正後)
// v2 配信の1広告あたり上限(K3 契約: durationSec ≤ 300 秒)。ここを通すと申込・契約・入金まで
// 成立した枠が、配信時に恒久 400 → dead で「売れたのに永久に映らない」になる(2026-07-25 監査)。
// 既定の枠(定員10×30秒)ではちょうど 300 秒で発火しない=正規の申込は妨げない。
if (units * loop.slot_seconds > 300) {
  const maxUnits = Math.max(1, Math.floor(300 / loop.slot_seconds));
  return {
    ok: false,
    error: `1回のお申し込みでご利用いただける掲載の長さは合計300秒(この枠では ${maxUnits}枠)までです。枠数を減らしてお申し込みください。`,
  };
}

拒否するだけでなく、300 ÷ slot_secondsで「この枠なら何枠まで申し込めるか」を計算し、そのままエラーメッセージに含めている。申込者は上限値そのものではなく、自分がいま選んでいる枠での具体的な上限枠数を知ることになる。

再発防止として見えたこと

この修正自体は、v2側がすでに持っていた300という上限値を、申込側にもう一箇所書き写しただけだ。だが根拠を見る限り、この値はkimiteras-portalのbooking.tsとキミテラス-v2のdelivery-payload.tsに、それぞれ別のリテラル300として独立に存在している。 単一ソースを参照する構造にはなっていない。

申込側と配信側が別リポジトリ・別システムである以上、この重複自体は今回の修正でも解消されていない。どちらか一方だけが将来変更されれば、同じ種類の食い違いが再発する構造は残っている。

よくある質問

Q1なぜ申込の入力時点では弾けなかったのですか?

入力時バリデーションは『枠の残り数』しかチェックしていなかった。units(枠数)がremainingUnits(残り枠)を超えていないかは見ていたが、units×slot_seconds(1注文の合計配信秒数)がv2配信側の上限と一致しているかは、どこも検証していなかった。

Q2恒久的な400エラーとはどういう状態ですか?

v2側のPartner API(K3契約)はdurationSecをzodスキーマでmin1・max300と検証しており、この検証は『payload形不正・enum外・UUID不正・ads 0件は再送で直らないので400/422』という設計になっている。一度このエラーになった注文は、何度再送しても同じ400が返り続け、申込・契約・入金が完了済みのまま配信だけが永久に失敗する。

Q3直し方はどう変えましたか?

booking.tsのbookLoopSlotCoreに、units×loop.slot_secondsが300を超える場合はその場で拒否するチェックを追加した。拒否するだけでなく、300÷slot_secondsで利用可能な最大枠数を算出し、『枠数を減らしてお申し込みください』という具体的な上限をエラーメッセージに含めている。

確認した環境

  • kimiteras-portal(申込フォーム)→ キミテラス-v2 Partner API(K3契約)
  • v2側 durationSec 上限 300秒(apps/web/lib/partner/delivery-payload.ts)
  • 2026-07-25 監査で発見・同日修正(コミットcf19ac2)

この記事の根拠

  • TypeScriptファイル 1253〜1261行目コミット 591b281
  • TypeScriptファイル 1262〜1271行目コミット cf19ac2
  • TypeScriptファイル 20〜21行目コミット 255c04e
  • TypeScriptファイル 37〜38行目コミット 255c04e
  • TypeScriptファイル 100〜103行目コミット 255c04e

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。