durationSec上限300秒を入力時に見ておらず、合計時間上限を超えても保存後の配信時に恒久400エラー
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
申込フォームの入力時バリデーションには合計配信時間の上限チェックが無く、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
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。